diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3559c5d0..8f67eff8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,7 +18,7 @@ jobs: python-version: 3.x architecture: x64 - name: Install setuptools - run: pip install setuptools + run: pip install setuptools==76.1.0 - name: Build a source distribution run: python setup.py sdist - name: Publish to prod PyPI diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9bddaadf..3ae373a8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,11 +13,13 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-20.04, windows-latest, macos-latest] + 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 }} @@ -31,7 +33,7 @@ jobs: - name: Setup test execution environment. run: pip3 install -r requirements-meta.txt - name: Run tox tests - run: tox -- --durations=0 --timeout=30 + run: tox -- --durations=0 --timeout=240 typecheck: strategy: diff --git a/CHANGELOG.md b/CHANGELOG.md index 32381939..b91f88d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # Change log +### 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 diff --git a/README.md b/README.md index 6b70d27d..a2dfc4dc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,8 @@ # 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. + [![PyPI version](https://img.shields.io/pypi/v/Eel?style=for-the-badge)](https://pypi.org/project/Eel/) [![PyPi Downloads](https://img.shields.io/pypi/dm/Eel?style=for-the-badge)](https://pypistats.org/packages/eel) ![Python](https://img.shields.io/pypi/pyversions/Eel?style=for-the-badge) @@ -100,7 +103,7 @@ 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.12.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'`* + - **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`* diff --git a/eel/__init__.py b/eel/__init__.py index 0dfa8864..34fc7ce1 100644 --- a/eel/__init__.py +++ b/eel/__init__.py @@ -1,15 +1,10 @@ +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, TYPE_CHECKING - -if TYPE_CHECKING: - from eel.types import OptionsDictT, WebSocketT -else: - WebSocketT = Any - OptionsDictT = Any - -from gevent.threading import Timer +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 @@ -23,14 +18,20 @@ import pyparsing as pp import random as rnd import sys -import pkg_resources as pkg +import importlib_resources import socket import mimetypes mimetypes.add_type('application/javascript', '.js') -_eel_js_file: str = pkg.resource_filename('eel', 'eel.js') -_eel_js: str = open(_eel_js_file, encoding='utf-8').read() + +# 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]]]] = {} @@ -46,28 +47,10 @@ # Can be overridden through `eel.init` with the kwarg `js_result_timeout` (default: 10000) _js_result_timeout: int = 10000 -# All start() options must provide a default value and explanation here -_start_args: OptionsDictT = { - '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 - '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 - 'shutdown_delay': 1.0 # how long to wait after a websocket closes before detecting complete shutdown -} +# 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 === -_start_args['suppress_error'] = False api_error_message: str = ''' ---------------------------------------------------------------------------------- 'options' argument deprecated in v1.0.0, see https://github.com/ChrisKnott/Eel @@ -77,9 +60,45 @@ ''' # =============================================================================================== + # Public functions + 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 @@ -113,8 +132,28 @@ def decorator(function: Callable[..., Any]) -> Any: ) -def init(path: str, allowed_extensions: List[str] = ['.js', '.html', '.txt', '.htm', - '.xhtml', '.vue'], js_result_timeout: int = 10000) -> None: +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) @@ -145,14 +184,117 @@ def init(path: str, allowed_extensions: List[str] = ['.js', '.html', '.txt', '.h _js_result_timeout = js_result_timeout -def start(*start_urls: str, **kwargs: Any) -> None: - _start_args.update(kwargs) - - if 'options' in kwargs: - if _start_args['suppress_error']: - _start_args.update(kwargs['options']) - else: - raise RuntimeError(api_error_message) +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) @@ -160,24 +302,28 @@ def start(*start_urls: str, **kwargs: Any) -> None: _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'") + 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']))) + 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() -> None: - if _start_args['all_interfaces'] == True: + if _start_args['all_interfaces'] is True: HOST = '0.0.0.0' else: if not isinstance(_start_args['host'], str): @@ -196,7 +342,7 @@ def run_lambda() -> None: port=_start_args['port'], server=wbs.GeventWebSocketServer, quiet=True, - app=app) # Always returns None + app=app) # Always returns None # Start the webserver if _start_args['block']: @@ -206,18 +352,78 @@ def run_lambda() -> None: 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 + + 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: 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 + def _eel() -> str: start_geometry = {'default': {'size': _start_args['size'], 'position': _start_args['position']}, @@ -231,12 +437,14 @@ def _eel() -> str: _set_response_headers(btl.response) return page -def _root() -> Optional[btl.Response]: + +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) -> Optional[btl.Response]: + +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): @@ -244,7 +452,7 @@ def _static(path: str) -> Optional[btl.Response]: template_prefix = _start_args['jinja_templates'] + '/' if path.startswith(template_prefix): n = len(template_prefix) - template = _start_args['jinja_env'].get_template(path[n:]) # type: ignore # depends on conditional import in start() + template = _start_args['jinja_env'].get_template(path[n:]) response = btl.HTTPResponse(template.render()) if response is None: @@ -253,6 +461,7 @@ def _static(path: str) -> Optional[btl.Response]: _set_response_headers(response) return response + def _websocket(ws: WebSocketT) -> None: global _websockets @@ -286,21 +495,34 @@ def _websocket(ws: WebSocketT) -> None: "/eel": (_websocket, dict(apply=[wbs.websocket])) } + def register_eel_routes(app: btl.Bottle) -> None: - ''' - Adds eel routes to `app`. Only needed if you are passing something besides `bottle.Bottle` to `eel.start()`. - Ex: - app = bottle.Bottle() - eel.register_eel_routes(app) - middleware = beaker.middleware.SessionMiddleware(app) - eel.start(app=middleware) + '''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: Any) -> str: return jsn.dumps(obj, default=lambda o: None) @@ -348,7 +570,7 @@ def _process_message(message: Dict[str, Any], ws: WebSocketT) -> None: def _get_real_path(path: str) -> str: if getattr(sys, 'frozen', False): - return os.path.join(sys._MEIPASS, path) # type: ignore # sys._MEIPASS is dynamically added by PyInstaller + return os.path.join(sys._MEIPASS, path) # type: ignore # sys._MEIPASS is dynamically added by PyInstaller else: return os.path.abspath(path) diff --git a/eel/__main__.py b/eel/__main__.py index b4027eb6..74910995 100644 --- a/eel/__main__.py +++ b/eel/__main__.py @@ -1,3 +1,4 @@ +from __future__ import annotations import pkg_resources as pkg import PyInstaller.__main__ as pyi import os diff --git a/eel/browsers.py b/eel/browsers.py index d555b2d9..183dd905 100644 --- a/eel/browsers.py +++ b/eel/browsers.py @@ -1,3 +1,4 @@ +from __future__ import annotations import subprocess as sps import webbrowser as wbr from typing import Union, List, Dict, Iterable, Optional @@ -7,13 +8,15 @@ 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: Dict[str, str] = {} _browser_modules: Dict[str, ModuleType] = {'chrome': chm, 'electron': ele, - 'edge': edge} + 'edge': edge, + 'msie':ie} def _build_url_from_dict(page: Dict[str, str], options: OptionsDictT) -> str: @@ -51,7 +54,7 @@ def open(start_pages: Iterable[Union[str, Dict[str, str]]], options: OptionsDict start_urls = _build_urls(start_pages, options) mode = options.get('mode') - if not isinstance(mode, (str, bool, type(None))) or mode is True: + 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 diff --git a/eel/chrome.py b/eel/chrome.py index c07356f0..a8112f5d 100644 --- a/eel/chrome.py +++ b/eel/chrome.py @@ -1,6 +1,9 @@ -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 @@ -57,16 +60,15 @@ def _find_chromium_mac() -> Optional[str]: def _find_chrome_linux() -> Optional[str]: - import whichcraft as wch 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 # type: ignore # whichcraft doesn't currently have type hints + return chrome return None diff --git a/eel/edge.py b/eel/edge.py index 7f2dab1e..7d785233 100644 --- a/eel/edge.py +++ b/eel/edge.py @@ -1,3 +1,4 @@ +from __future__ import annotations import platform import subprocess as sps import sys @@ -9,9 +10,16 @@ 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) - + 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() -> bool: if platform.system() == 'Windows': diff --git a/eel/electron.py b/eel/electron.py index 14cbc802..4ff8fcc1 100644 --- a/eel/electron.py +++ b/eel/electron.py @@ -1,8 +1,8 @@ -#from __future__ import annotations +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 from eel.types import OptionsDictT @@ -20,11 +20,10 @@ def run(path: str, options: OptionsDictT, start_urls: List[str]) -> None: 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') # type: ignore # whichcraft doesn't currently have type hints - 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/types.py b/eel/types.py index 55475816..0cd2d6ee 100644 --- a/eel/types.py +++ b/eel/types.py @@ -1,28 +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.7 doesn't have __future__/annotations, and <3.10 doesn't -# support TypeAlias. +# 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 - try: - from typing import TypeAlias # Introduced in Python 3.10 - JinjaEnvironmentT: TypeAlias = Environment - except ImportError: - JinjaEnvironmentT = Environment # type: ignore + JinjaEnvironmentT: TypeAlias = Environment from geventwebsocket.websocket import WebSocket - WebSocketT = WebSocket + WebSocketT: TypeAlias = WebSocket else: - JinjaEnvironmentT = None - WebSocketT = Any + JinjaEnvironmentT: TypeAlias = Any + WebSocketT: TypeAlias = Any -OptionsDictT = Dict[ - str, - Optional[ - Union[ - str, bool, int, float, - List[str], Tuple[int, int], Dict[str, Tuple[int, int]], - Callable[..., Any], JinjaEnvironmentT - ] - ] - ] +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/07 - CreateReactApp/README.md b/examples/07 - CreateReactApp/README.md index f2b0f249..3f6a85b6 100644 --- a/examples/07 - CreateReactApp/README.md +++ b/examples/07 - CreateReactApp/README.md @@ -19,7 +19,7 @@ If you run into any issues with this example, open a [new issue](https://github. ## Quick Start -1. **Configure:** In the app's directory, run `npm install` and `pip install bottle bottle-websocket future whichcraft pyinstaller` +1. **Configure:** In the app's directory, run `npm install` and `pip install bottle bottle-websocket future pyinstaller` 2. **Demo:** Build static files with `npm run build` then run the application with `python eel_CRA.py`. A Chrome-app window should open running the built code from `build/` 3. **Distribute:** (Run `npm run build` first) Build a binary distribution with PyInstaller using `python -m eel eel_CRA.py build --onefile` (See more detailed PyInstaller instructions at bottom of [the main README](https://github.com/ChrisKnott/Eel)) 4. **Develop:** Open two prompts. In one, run `python eel_CRA.py true` and the other, `npm start`. A browser window should open in your default web browser at: [http://localhost:3000/](http://localhost:3000/). As you make changes to the JavaScript in `src/` the browser will reload. Any changes to `eel_CRA.py` will require a restart to take effect. You may need to refresh the browser window if it gets out of sync with eel. diff --git a/mypy.ini b/mypy.ini index 6fa89652..4be68351 100644 --- a/mypy.ini +++ b/mypy.ini @@ -5,9 +5,6 @@ warn_unused_configs = True [mypy-bottle_websocket] ignore_missing_imports = True -[mypy-jinja2] -ignore_missing_imports = True - [mypy-gevent] ignore_missing_imports = True @@ -26,12 +23,6 @@ ignore_missing_imports = True [mypy-bottle.ext.websocket] ignore_missing_imports = True -[mypy-whichcraft] -ignore_missing_imports = True - -[mypy-pyparsing] -ignore_missing_imports = True - [mypy-PyInstaller] ignore_missing_imports = True diff --git a/requirements-test.txt b/requirements-test.txt index 4ee6b5dc..ec2da232 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -8,3 +8,4 @@ webdriver_manager>=4.0.0,<5.0.0 mypy>=1.0.0,<2.0.0 pyinstaller types-setuptools +importlib_resources>=1.3 diff --git a/requirements.txt b/requirements.txt index af6da07d..77d8646e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ gevent gevent-websocket<1.0.0 greenlet>=1.0.0,<2.0.0 pyparsing>=3.0.0,<4.0.0 -whichcraft~=0.4.1 +typing-extensions>=4.3.0 +importlib_resources>=1.3 diff --git a/setup.py b/setup.py index 03dccdf0..000dfbeb 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ setup( name='Eel', - version='0.17.0', + version='0.18.2', author='Python Eel Organisation', author_email='python-eel@protonmail.com', url='https://github.com/python-eel/Eel', @@ -14,7 +14,7 @@ package_data={ 'eel': ['eel.js', 'py.typed'], }, - install_requires=['bottle', 'bottle-websocket', 'future', 'pyparsing', 'whichcraft'], + install_requires=['bottle', 'bottle-websocket', 'future', 'pyparsing', 'typing_extensions', 'importlib_resources'], extras_require={ "jinja2": ['jinja2>=2.10'] }, diff --git a/tox.ini b/tox.ini index 272bb77e..0d3838e4 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = typecheck,py{37,38,39,310,311,312} +envlist = typecheck,py{37,38,39,310,311,312,313} [pytest] timeout = 30 @@ -12,6 +12,7 @@ python = 3.10: py310 3.11: py311 3.12: py312 + 3.13: py313 [testenv]