Source code for toui.pages

"""
A module that creates web pages or windows.
"""
import os
import time
from bs4 import BeautifulSoup
import webview
import json
from flask import session, redirect, request, Response
from toui.elements import Element
from toui._javascript_templates import custom_func, get_script
from copy import copy
from toui._helpers import warn, info, debug, selector_to_str, obj_converter
from toui._signals import Signal


class _PageSignal(Signal):
    """
    Creates signals that will be sent to JavaScript.

    These signals are related the methods of the `Page` object.
    """

    no_return_functions = ["add_function"]

    @staticmethod
    def from_str(**kwargs):
        js_func = "_setDoc"
        js_args = []
        js_kwargs = {"doc": kwargs['html_str']}
        return {'func': js_func, 'args': js_args, 'kwargs': js_kwargs}

    @staticmethod
    def from_bs4_soup(**kwargs):
        js_func = "_setDoc"
        js_args = []
        js_kwargs = {"doc": str(kwargs['bs4_soup'])}
        return {'func': js_func, 'args': js_args, 'kwargs': js_kwargs}

    @staticmethod
    def add_function(**kwargs):
        value = kwargs['return_value']
        if value != "":
            value = custom_func(value)
        js_func = "_addScript"
        js_args = []
        js_kwargs = {"script": value}
        return {'func': js_func, 'args': js_args, 'kwargs': js_kwargs}

    @staticmethod
    def _open_another_page(**kwargs):
        js_func = "_goTo"
        js_args = []
        js_kwargs = {"url": kwargs['url'], "new": kwargs['new'], "different_origin": kwargs['different_origin']}
        return {'func': js_func, 'args': js_args, 'kwargs': js_kwargs}


[docs]class Page: """ Creates an HTML page that can be used as a window or as a webpage. Attributes ---------- url: str The URL of the page. title: str In desktop app, this attribute will be the title of the window. window_defaults: dict In desktop apps, this dictionary sets the default parameters of the window. To set a certain default parameter for a window before creating it, include it in this dictionary. The parameters that can be set are the keyword arguments of the class [`webview.create_window()`](https://pywebview.flowrl.com/guide/api.html) in pywebview package. Examples -------- Importing the class: >>> from toui import Page Creating a `Page` from an HTML file: >>> path = "../examples/assets/test1.html" >>> page = Page(html_file=path) Creating a `Page` from a string: >>> page = Page(html_str="<html><h1>Hello</h1></html>") >>> page <html><h1>Hello</h1></html> Creating a page with a URL: >>> page = Page(html_str="<html><h1>Hello</h1></html>", url="/") Getting an element in the `Page` from its id: >>> page = Page(html_str='<html><h1 id="heading">Hello</h1></html>', url="/") >>> element = page.get_element(element_id="heading") >>> element <h1 id="heading">Hello</h1> See Also -------- `pywebview api`: https://pywebview.flowrl.com/guide/api.html """ def __init__(self, html_file=None, html_str=None, url=None, title=None): """ Parameters ---------- html_file: str The path to the HTML file. html_str: str A string containing HTML code. url: str, optional If the page was used as a webpage, this will be the URL of the page. title: str In desktop app, this parameter will be the title of the window. """ if html_file: self._html_file = html_file with open(html_file, "rt") as file: original_html = file.read() if url is None: url = "/" + os.path.basename(html_file) elif html_str: original_html = html_str self._html_file = None else: original_html = "<html></html>" self._html_file = None self._html = BeautifulSoup(original_html, features="html.parser") if self._html.find("html") is None: self._html = BeautifulSoup(f"<html>{original_html}</html>", features="html.parser") if url is None: url = "/" self.url = url # Other attributes self.title = title self.window_defaults = {} # Other internal attributes self._app = None self._signal_mode = False self._signals = [] self._functions = {} self._view_func = self._on_url_request self._uid = None self._navigation_bar = "" self._footer = "" def __str__(self): return self.to_str() def __repr__(self): return self.to_str() def __copy__(self): new_pg = Page(html_file=self._html_file, url=self.url) new_pg.from_bs4_soup(self.to_bs4_soup()) new_pg._signal_mode = self._signal_mode new_pg._navigation_bar = self._navigation_bar new_pg._footer = self._footer new_pg._app = self._app return new_pg
[docs] @_PageSignal() def from_str(self, html_str): """ Converts HTML code to a `Page` object. Warning ------- If this method is called after the app starts running, it will call the JavaScript function `document.write()`. This might lead to potential security issues so use it with care. Parameters ---------- html_str: str HTML code. """ self._html = BeautifulSoup(html_str, features="html.parser")
[docs] def to_str(self): """ Converts the `Page` object to HTML code. Returns ------- str """ return str(self._html)
[docs] def from_html_file(self, html_path): """ Reads an HTML file and converts it to an `Page` object. Warning ------- If this method is called after the app starts running, it will call the JavaScript function `document.write()`. This might lead to potential security issues so use it with care. Parameters ---------- html_path: str """ with open(html_path, "rt") as file: self.from_str(file.read())
[docs] def to_html_file(self, html_path): """ Converts the `Page` object to an HTML file. Parameters ---------- html_path: str """ with open(html_path, "w") as file: file.write(self.to_str())
[docs] @_PageSignal() def from_bs4_soup(self, bs4_soup): """ Converts a `bs4.BeautifulSoap` object to a `Page` object. The `bs4.BeautifulSoap` object will be copied before converting. Warning ------- If this method is called after the app starts running, it will call the JavaScript function `document.write()`. This might lead to potential security issues so use it with care. Parameters ---------- bs4_soup: bs4.BeautifulSoap See Also -------- bs4 """ self._html = copy(bs4_soup)
[docs] def to_bs4_soup(self): """ Converts the `Page` object to a `bs4.BeautifulSoap` object. The `Page` object will be copied before converting. Returns ------- bs4.BeautifulSoap See Also -------- bs4: https://beautiful-soup-4.readthedocs.io/en/latest/ """ return copy(self._html)
[docs] def get_element(self, element_id, do_copy=False): """ Gets an element from its ``id`` attribute. You can imagine this function as ``document.getElementById`` in JavaScript. Creating a page and getting an element by its ``id``: >>> page = Page(html_str='<html><h1 id="heading">Hello</h1></html>', url="/") >>> element = page.get_element(element_id="heading") >>> element <h1 id="heading">Hello</h1> Parameters ---------- element_id: str do_copy: bool, default = False If ``True``, the element will be copied. Returns ------- element: Element If the element was found, an `Element` object will be returned. Otherwise ``None`` will be returned. """ bs4_tag = self._html.find(id=element_id) if bs4_tag is None: return None element = Element() if do_copy: element.from_bs4_tag(bs4_tag) else: element._from_bs4_tag_no_copy(bs4_tag) element._parent_page = self element._signal_mode = self._signal_mode return element
[docs] def get_elements(self, tag_name=None, class_name=None, name=None, do_copy=False, attrs=None): """ Get elements from the `Page` by their tag name and attributes. Parameters ---------- tag_name: str, default=None The tag name of the elements. class_name: str, default=None The value of the ``class`` attribute of the elements. name: str, default=None The value of the ``name`` attribute of the elements. do_copy: bool If ``True``, the elements will be copied. attrs: dict, default=None Attributes other than ``class`` and ``name`` can be specified in this dictionary. Returns ------- elements_list: List A list of `Element` objects that match the parameters. """ if attrs is None: attrs = {} if class_name: attrs['class'] = class_name if name: attrs['name'] = name bs4_tags = self._html.find_all(name=tag_name, attrs=attrs) elements_list = [] for tag_num, bs4_tag in enumerate(bs4_tags): element = Element() if do_copy: element.from_bs4_tag(bs4_tag) else: element._from_bs4_tag_no_copy(bs4_tag) element._parent_page = self element._signal_mode = self._signal_mode elements_list.append(element) return elements_list
[docs] def get_element_from_selector(self, selector, do_copy=False): """ Gets an element from its CSS selector. Parameters ---------- selector: str do_copy: bool, default = False If ``True``, the element will be copied. Returns ------- element: Element If the element was found, an `Element` object will be returned. Otherwise ``None`` will be returned. """ bs4_tag = self._html.select_one(selector=selector) if bs4_tag is None: return None element = Element() if do_copy: element.from_bs4_tag(bs4_tag) else: element._from_bs4_tag_no_copy(bs4_tag) element._parent_page = self element._signal_mode = self._signal_mode return element
[docs] def get_html_element(self) -> Element: """ Gets the first ``<html>`` element. Returns ------- Element None If the ``<html>`` element was not found. """ elements = self.get_elements("html") if len(elements) > 0: return elements[0]
[docs] def get_body_element(self) -> Element: """ Gets the first ``<body>`` element. Returns ------- Element None If the ``<body>`` element was not found. """ elements = self.get_elements("body") if len(elements) > 0: return elements[0]
[docs] @_PageSignal() def add_function(self, func): """ Adds a function to the `Page`. This function can be called from an HTML element. Warning ------- If you added a Python function, users might be able to call this function from the client-side. Choose wisely the functions you add. Examples -------- Consider an HTML code that contains the following: >>> html_with_function = '<html><button onclick="printValue()">Print</button></html>' Notice that the ``<button>`` element contains the attribute ``onclick`` calling a Python function `printValue`. However, this function is not yet added to the HTML page. To add the function, define it in Python and add it to a `Page`: >>> def printValue(): ... print("value") >>> page = Page(html_str=html_with_function) >>> page.add_function(printValue) Now create a `Website` and add the `Page` to it: >>> from toui import Website >>> app = Website() >>> app.add_pages(page) Parameters ---------- function Function to be added to the page. Returns ------- None """ 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.") old_functions = copy(self._functions) self._functions[name] = func if func.__name__ in old_functions: return "" script_element = Element("script") script_element.set_content(custom_func(func.__name__)) self.get_elements(tag_name="html")[0].add_content(script_element) return func.__name__
[docs] def on_url_request(self, func, display_return_value=False): """ Sets a function that will be called when the user types the URL in a browser or when a request is sent to the URL. You can view it as the `view_func` in Flask. It might have limited functionality compared to calling Python functions from HTML, but it is the best for retrieving data from HTTP requests. Parameters ---------- func: Callable The function that will be called when a request is sent to the URL. display_return_value: bool, default=False If ``True``, the browser will display the return value of the function when the URL is loaded. If ``False``, the return value will be ignored. """ new_func = lambda: self._on_url_request(func=func, display_return_value=display_return_value) self._view_func = new_func
[docs] def set_navigation_bar(self, html_file=None, html_str=None): """ Adds a navigation bar to the page. Parameters ---------- html_file: str, default=None Path to the HTML file that contains the navigation bar. html_str: str, default=None HTML code of the navigation bar. Returns ------- None """ if html_file is not None: with open(html_file, "r") as f: self._navigation_bar = f.read() if html_str is not None: self._navigation_bar = html_str
[docs] def get_window(self): for window in webview.windows: if window.uid == self._uid: return window
def _on_url_request(self, func=None, display_return_value=False): self._app._user_vars._gen_sid() session['user page'] = copy(self) session['toui-request-url'] = request.url session['toui-page-vars'] = {} try: pg = session['user page'] body_element = pg.get_body_element() if body_element: body_element.set_content(pg._navigation_bar + body_element.to_str()) body_element.add_content(pg._footer) else: pg.from_str(pg._navigation_bar + pg.to_str() + pg._footer) if func: new_return = func() if display_return_value: del session['user page'] if "toui-response" in session: response = session['toui-response'] if isinstance(new_return, Response): response.set_data(new_return.get_data()) else: response.set_data(new_return) del session['toui-response'] return response else: return new_return pg = session['user page'] del session['user page'] if "toui-response" in session: response = session['toui-response'] response.set_data(pg.to_str()) del session['toui-response'] return response else: return pg.to_str() except Exception as e: if 'user page' in session: del session['user page'] if 'toui-response' in session: del session['toui-response'] raise e def _add_script(self): script_tag = Element("script") script_content = get_script(self._app.__class__.__name__) script_tag.set_content(script_content) self.get_elements(tag_name="html")[0].add_content(script_tag) def _create_window(self): title = self.title url = f"http://localhost:{self._app._port}"+self.url window_defaults = self.window_defaults.copy() for key, value in window_defaults.items(): if key == "title": title = value del window_defaults['title'] if key == "url": warn(f"The window will load the URL '{value}' instead of '{self.url}' because it was set in `default_windows`.") url = value del window_defaults['url'] window = webview.create_window(title=title, url=url, **window_defaults) self._uid = window.uid debug(f"UID of window: {self._uid}") def get_uid(): return self._uid window.expose(get_uid) return window def _evaluate_js(self, func, kwargs): """This function is currently unused.""" data_from_js = "" def wait_then_get_result(result): nonlocal data_from_js data_from_js = result codejs = f""" var kwargs = JSON.parse(\'{json.dumps(kwargs)}\') {func}(kwargs) """ debug("EVALUATE: " + codejs) self.window.evaluate_js(codejs, callback=wait_then_get_result) return data_from_js @_PageSignal(app_types=['Website']) def _open_another_page(self, url, new, different_origin): if self._app.__class__.__name__ == "DesktopApp": if new: pg = Page(url=url) pg._app = self._app return pg._create_window() else: full_url = f"http://localhost:{self._app._port}" + url if different_origin: full_url = url window = self.get_window() window.load_url(full_url) return window def _inherit_functions(self): for page in self._app.pages: if page.url == self.url: self._functions.update(page._functions) return 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') return functions[func_name](*args)
[docs]class RedirectingPage(Page): """ An empty page that redirects to another page when it is loaded. Parameters ---------- redirect_to: str The URL of the page to redirect to. url: str The URL of the empty page. title: str, default=None The title of the empty page. """ def __init__(self, redirect_to, url, title=None): super().__init__(url=url, title=title) self.on_url_request(lambda: redirect(redirect_to), display_return_value=True)
if __name__ == "__main__": import doctest doctest.testmod()