"""
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()