Source code for toui.apps

"""
A module that creates web apps and desktop apps.
"""
import __main__
from flask import Flask, session
from flask_sock import Sock
import webview
import json
import inspect
import time
import os
from copy import copy
from abc import ABCMeta, abstractmethod
from flask_basicauth import BasicAuth
from toui._helpers import warn, info, debug, error
from toui.pages import Page
from toui.exceptions import ToUIWrongPlaceException
from toui._defaults import validate_ws, validate_data


class _App(metaclass=ABCMeta):
    """The base class for DesktopApp and Website"""

    def _add_function(self, func):
        name = func.__name__
        if not callable(func):
            warn(f"Variable '{name}' is not a function.")
            return
        if name.startswith("_"):
            warn(f"Function '{name}' starts with '_'. It is safer to avoid functions that starts with '_'"
                 f"because they might overlap with functions used by the package.")
        if self._func_exists(name):
            warn(f"Function '{name}' exists.")
        self._functions[name] = func

    @abstractmethod
    def add_pages(self): pass

    @abstractmethod
    def open_new_page(self, url): pass

    @abstractmethod
    def get_user_page(self): pass

    @abstractmethod
    def user_vars(self): pass

    @abstractmethod
    def _communicate(self): pass

    @abstractmethod
    def run(self): pass

    def _add_functions(self, *functions):
        for func in functions:
            self._add_function(func)

    def _get_functions(self):
        """Gets all added functions in this class. This is a private function."""
        return self._functions

    def _func_exists(self, func_name: str):
        """Checks if a function exists. This is a private function."""
        if func_name in self._get_functions().keys():
            return True
        else:
            return False

    def _call_func(self, func_name, *args, page=None):
        """Calls a function in this class. Its return value depends on the function called. This is a private function."""
        functions = self._get_functions()
        info(f'"{func_name}" called')
        if page:
            func = _FuncWithPage(functions[func_name], page)
            return func(*args)
        return functions[func_name](*args)


class _FuncWithPage:
    def __init__(self, func, page):
        self.func = func
        self.page = page
    def __call__(self, *args, **kwargs):
        return self.func(*args, **kwargs)

        
[docs]class Website(_App): """ A class that create a web application from HTML files. Attributes ---------- flask_app: Flask ToUI creates web applications using `Flask`. You can access the `Flask` object using the attribute `flask_app`. forbidden_urls: list These are URLs that ToUI does not allow the user to use because ToUI uses them. user_vars: dict A dictionary that stores data unique to each user. The data are stored in `flask` `session` object. pages: list A list of added `Page` objects. Examples -------- Creating a web app: >>> from toui import Website >>> app = Website(__name__, secret_key="some key") Creating a page and adding it to the app: >>> from toui import Page >>> home_page = Page(html_str="<h1>This is the home page</h1>") >>> app.add_pages(home_page) Running the app: >>> if __name__ == "__main__": ... app.run(debug=True) # doctest: +SKIP See Also -------- :py:class:`toui.pages.Page` :py:class:`DesktopApp` """ _auth = None _default_vars = {} def __init__(self, name=None, assets_folder=None, secret_key=None): """ Parameters ---------- name: str (optional) The name of the app. assets_folder: str (optional) The path to the folder that contains the HTML files and other assets. secret_key: str (optional) Sets the `secret_key` attribute for `flask.Flask` """ self._functions = {} if not name: if hasattr(__main__, "__file__"): name = os.path.basename(__main__.__file__).split(".")[0] else: name = "app" if not assets_folder: assets_folder = "/" self.flask_app = Flask(name, static_folder=assets_folder, static_url_path="/") if secret_key is None: warn("No secret key was set. Generating a random secret key for Flask.") secret_key = os.urandom(50) self.flask_app.secret_key = secret_key self._socket = Sock(self.flask_app) self.pages = [] self._socket.route("/toui-communicate")(self._communicate) self.forbidden_urls = ['/toui-communicate'] self._validate_ws = validate_ws self._validate_data = validate_data
[docs] def add_pages(self, *pages, do_copy=False, blueprint=None, endpoint=None): """ Adds pages to the app. Parameters ---------- pages: list(Page) List of `Page` objects. do_copy: bool, default = False If ``True``, the `Page` will be copied before adding to the app. blueprint: toui.structure.ToUIBlueprint, flask.Blueprint, default = None If a `flask.Blueprint` or a `ToUIBlueprint` was added, the `Page` view_func will be added to the blueprint instead of the `Flask` app. endpoint `endpoint` parameter in `flask.Flask.route`. """ for page in pages: if page.url in self.forbidden_urls: warn(f"The URL '{page.url}' is not allowed and might cause errors.") if do_copy: page = copy(page) page._add_script() page._app = self self.pages.append(page) view_func = page.view_func if self._auth: view_func = self._auth.required(view_func) if not endpoint: endpoint = page.url if blueprint: route = blueprint.route(page.url, methods=['GET', 'POST'], endpoint=page.url)(view_func) else: route = self.flask_app.route(page.url, methods=['GET', 'POST'], endpoint=page.url)(view_func) for func in page._functions.values(): self._add_functions(func)
[docs] def open_new_page(self, url): """ Redirects to another URL. This function should only be called after the app starts running. Parameters ---------- url: str URL of the new page. Returns ------- None """ try: session.keys() self.get_user_page()._open_another_page(url) except RuntimeError: raise ToUIWrongPlaceException(f"The function `{inspect.currentframe().f_code.co_name}` should only be called after the app runs.")
[docs] def get_user_page(self): """ Returns the current `Page`. This function should only be called after the app starts running. Returns ------- pg: Page """ try: pg = session['user page'] return pg except RuntimeError as e: raise ToUIWrongPlaceException(f"The function `{inspect.currentframe().f_code.co_name}` should only be called after the app runs.")
[docs] def run(self, *args, **kwargs): """ Runs the app. It calls the function `flask.Flask.run`. The arguments will be passed to `flask.Flask.run` function. Parameters ---------- args: Any kwargs: Any """ self.flask_app.run(*args, **kwargs)
@property def user_vars(self): session_exists = self._session_check() if session_exists: return session['variables'] else: return self._default_vars def _session_check(self): """This is a private function.""" try: session.keys() except RuntimeError as e: return False if not "variables" in session.keys(): session['variables'] = self._default_vars if not "user page" in session.keys(): session['user page'] = None return True def _communicate(self, ws): """This is a private function.""" validation = self._validate_ws(ws) if not validation: info("WebSocket validation returns `False`. No data should be sent or received.") return info(f'WebSocket connected: {ws.connected}') while True: data_from_js = ws.receive() data_validation = self._validate_data(data_from_js) if not data_validation: info("Data validation returns `False`. The data will not be used.") continue debug(data_from_js) s = time.time() self._session_check() data_dict = json.loads(data_from_js) func = data_dict['func'] args = data_dict['args'] url = data_dict['url'] new_html = data_dict['html'] new_page = Page(url=url) new_page.from_str(new_html) new_page._app = self new_page._signal_mode = True new_page._ws = ws session['user page'] = new_page if self._func_exists(func): self._call_func(func, *args) del session['user page'] e = time.time() debug(f"TIME: {e - s}s") # Website-specific methods
[docs] def set_restriction(self, username, password): """ Makes the app private. Adds a username and password to the app. Parameters ---------- username: str password: str """ self._auth = BasicAuth(self.flask_app) self.flask_app.config['BASIC_AUTH_USERNAME'] = username self.flask_app.config['BASIC_AUTH_PASSWORD'] = password
[docs] def set_ws_validation(self, func): """ Validate `simple_websocket.ws.Server` object before sending and accepting data. ToUI uses Flask-Sock for websocket communication. Flask-Sock generates a `simple_websocket.ws.Server` object when a connection is established. If you wanted to access this object before sending and receiving data, input a function that has one argument `ws`. This function should either return ``True`` or ``False``. If the function returns ``False``, no data will be sent or received using ToUI with the client. Parameters ---------- func: Callable A function that validates the Server object. It should have one argument `ws` and should either return ``True`` or ``False``. See Also -------- flask_sock simple_websockets """ self._validate_ws = func
[docs] def set_data_validation(self, func): """ Validate data received from JavaScript before using it. ToUI receives data from JavaScript in the form of JSON object. To validate this data before allowing ToUI to use it, input a function that checks the data. This function should have one argument `data` and should either return ``True`` or ``False``. If the function returns ``False``, the data will not be used by ToUI. Currently, the JSON object contains the following keys: {func: ..., args: ..., url: ..., html: ...} `func` contains the name of the Python function that should be called, `args` are the arguments of this function, `url` is the URL of the HTML page that sent the data, 'html' if the HTML document itself as a string. However, note that the structure of the JSON object might change in future versions of ToUI. Parameters ---------- func: Callable A function that validates data received from JavaScript. It should have one argument `data` and should either return ``True`` or ``False``. See Also -------- Website.set_ws_validation """ self._validate_data = func
[docs] def register_toui_blueprint(self, blueprint, **options): """ Registers a `ToUIBlueprint` object. It is similar to `Flask.register_blueprint`. Parameters ---------- blueprint: toui.structure.ToUIBlueprint options Same as `Flask.register_blueprint` `options` parameter. See Also -------- toui.structure.ToUIBlueprint flask.Blueprint """ self.add_pages(*blueprint.pages, blueprint=blueprint) self.flask_app.register_blueprint(blueprint=blueprint, **options)
class _DesktopAppApi: def __init__(self, app): self.app = app def communicate(self, data_from_js): self.app._communicate(data_from_js, self.window)
[docs]class DesktopApp(_App): """ A class that create a desktop application from HTML files. Attributes ---------- name: str The name of the app pages: list A list of added `Page` objects. user_vars: dict A normal dictionary. It was created only to make the API for the `Website` class and `DesktopApp` class similar. Examples -------- Creating a desktop app: >>> from toui import DesktopApp >>> app = DesktopApp("MyApp") Creating a page and adding it to the app: >>> from toui import Page >>> home_page = Page(html_str="<h1>This is the home page</h1>") >>> app.add_pages(home_page) Running the app: >>> if __name__ == "__main__": ... app.run() # doctest: +SKIP See Also -------- :py:class:`toui.pages.Page` :py:class:`Website` """ def __init__(self, name="App", assets_folder=None): """ Parameters ---------- name: str The name of the app. """ self.name = name self.pages = [] if not assets_folder: assets_folder = "." self._assets_folder = assets_folder self._variables = {} self._functions = {} self._is_running = False
[docs] def add_pages(self, *pages, do_copy=False): """ Adds pages to the app. Parameters ---------- pages: list(Page) List of `Page` objects. do_copy: bool, default = False If ``True``, the `Page` will be copied before adding to the app. """ for page in pages: if do_copy: page = copy(page) page._add_script("desktop") page._app = self self.pages.append(page) for func in page._functions.values(): self._add_functions(func)
[docs] def open_new_page(self, url): """ Opens a new window that has the specified URL. This function should only be called after the app starts running. Parameters ---------- url: str URL of the new page. Returns ------- None """ for p in self.pages: if p.url == url: api = _DesktopAppApi(self) p._create_window(name=self.name, api=api, assets_folder=self._assets_folder)
[docs] def get_user_page(self): """ Returns the current `Page`. This function should only be called after the app starts running. Returns ------- pg: Page """ if not self._is_running: raise ToUIWrongPlaceException(f"The function `get_user_page` should only be called after the app runs.") func_frame = inspect.currentframe() func_type = type(None) while func_type is not _FuncWithPage: func_frame = func_frame.f_back if "self" in func_frame.f_locals: func_type = func_frame.f_locals['self'].__class__ if func_frame is None: raise Exception("Could not find page. Perhaps `get_user_page` was not called within a function" "called by HTML event?") page = func_frame.f_locals['self'].page return page
@property def user_vars(self): return self._variables
[docs] def run(self, *args, **kwargs): """ Runs the app. It calls the function `webview.start`. The arguments will be passed to `webview.start` function. Parameters ---------- args: Any kwargs: Any """ if len(self.pages) == 0: raise Exception("You need to have at least one `Page` added before running the app.") api = _DesktopAppApi(self) func = self.pages[0]._create_first_window(name=self.name, api=api, assets_folder=self._assets_folder) self._is_running = True webview.start(func)
def _communicate(self, data_from_js, window): debug("communicating") s = time.time() data_dict = json.loads(data_from_js) debug(data_dict) func = data_dict['func'] args = data_dict['args'] new_html = data_dict['html'] new_page = Page() new_page.from_str(new_html) new_page._app = self new_page._signal_mode = True new_page.window = window if self._func_exists(func): self._call_func(func, *args, page=new_page) e = time.time() debug(f"TIME: {e - s}s")
[docs]def quick_website(name="App", html_file=None, html_str=None, url="/", assets_folder=None, secret_key=None): """ Creates a web app and adds a single `Page` to it. Parameters ---------- name: str (optional) The name of the app. html_file: str (optional) The path to the HTML file that will be used to create the `Page`. html_str: str (optional) The content of the `Page`. url: str (optional) The URL of the `Page`. assets_folder: str (optional) The path to the folder that contains the HTML file. If no HTML files are used, you can ignore this parameter. secret_key: str (optional) Sets the `secret_key` attribute for `flask.Flask` Returns ------- Website """ app = Website(name=name, assets_folder=assets_folder, secret_key=secret_key) page = Page(url=url, html_file=html_file, html_str=html_str) app.add_pages(page) return app
[docs]def quick_desktop_app(name="App", html_file=None, html_str=None, url="/", assets_folder=None): """ Creates a desktop app and adds a single `Page` to it. Parameters ---------- name: str (optional) The name of the app. html_file: str (optional) The path to the HTML file that will be used to create the `Page`. html_str: str (optional) The content of the `Page`. url: str (optional) The URL of the `Page`. assets_folder: str (optional) The path to the folder that contains the HTML file. If no HTML files are used, you can ignore this parameter. Returns ------- DesktopApp """ app = DesktopApp(name=name, assets_folder=assets_folder) page = Page(url=url, html_file=html_file, html_str=html_str) app.add_pages(page) return app
if __name__ == "__main__": import doctest results = doctest.testmod() print(results)