Skip to content
This repository has been archived by the owner on Feb 15, 2024. It is now read-only.

Feature/frontend decoupling #1034

Closed
wants to merge 8 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions frontend/taipy-gui/src/components/Taipy/Navigate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ const Navigate = ({ to, params, tab, force }: NavigateProps) => {
navigate(0);
} else {
navigate({ pathname: to, search: `?${searchParams.toString()}` });
if (searchParams.has("tprh")) {
navigate(0);
}
}
} else {
window.open(`${to}?${searchParams.toString()}`, tab || "_blank")?.focus();
Expand Down
12 changes: 12 additions & 0 deletions src/taipy/gui/external/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Copyright 2023 Avaiga Private Limited
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
# specific language governing permissions and limitations under the License.

from ._custom_page import CustomPage, ResourceHandler
88 changes: 88 additions & 0 deletions src/taipy/gui/external/_custom_page.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Copyright 2023 Avaiga Private Limited
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on
# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
# specific language governing permissions and limitations under the License.

from __future__ import annotations

import typing as t
from abc import ABC, abstractmethod

from ..page import Page
from ..utils.singleton import _Singleton

if t.TYPE_CHECKING:
from ..gui import Gui


class CustomPage(Page):
"""A custom page for external application that can be added to Taipy GUI"""

def __init__(self, resource_handler: ResourceHandler, binding_variables: t.List[str] = None, **kwargs) -> None:
if binding_variables is None:
binding_variables = []
super().__init__(**kwargs)
self._resource_handler = resource_handler
self._binding_variables = binding_variables


class ResourceHandler(ABC):
"""Resource handler for custom pages

User can implement this class to provide custom resources for the custom pages
"""

id: str = ""

def __init__(self) -> None:
_ExternalResourceHandlerManager().register(self)

def get_id(self) -> str:
return self.id if id != "" else str(id(self))

@abstractmethod
def get_resources(self, path: str) -> t.Any:
raise NotImplementedError


class _ExternalResourceHandlerManager(object, metaclass=_Singleton):
"""Manager for resource handlers

This class is used to manage resource handlers for custom pages
"""

def __init__(self) -> None:
self.__handlers: t.Dict[str, ResourceHandler] = {}

def register(self, handler: ResourceHandler) -> None:
"""Register a resource handler

Arguments:
handler (ResourceHandler): The resource handler to register
"""
self.__handlers[handler.get_id()] = handler

def get(self, id: str) -> t.Optional[ResourceHandler]:
"""Get a resource handler by its id

Arguments:
id (str): The id of the resource handler

Returns:
ResourceHandler: The resource handler
"""
return self.__handlers.get(id, None)

def get_all(self) -> t.List[ResourceHandler]:
"""Get all resource handlers

Returns:
List[ResourceHandler]: The list of resource handlers
"""
return list(self.__handlers.values())
106 changes: 98 additions & 8 deletions src/taipy/gui/gui.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@
import warnings
from importlib import metadata, util
from importlib.util import find_spec
from types import FrameType, SimpleNamespace
from types import FrameType, FunctionType, LambdaType, ModuleType, SimpleNamespace
from urllib.parse import unquote, urlencode, urlparse

import __main__
import markdown as md_lib
import tzlocal
from flask import Blueprint, Flask, g, jsonify, request, send_file, send_from_directory
from flask import Blueprint, Flask, g, has_app_context, jsonify, request, send_file, send_from_directory
from werkzeug.utils import secure_filename

from taipy.logger._taipy_logger import _TaipyLogger
Expand All @@ -54,6 +54,7 @@
from .data.data_format import _DataFormat
from .data.data_scope import _DataScopes
from .extension.library import Element, ElementLibrary
from .external import CustomPage
from .page import Page
from .partial import Partial
from .server import _Server
Expand Down Expand Up @@ -538,6 +539,8 @@ def _get_client_id(self) -> str:
def __set_client_id_in_context(self, client_id: t.Optional[str] = None, force=False):
if not client_id and request:
client_id = request.args.get(Gui.__ARG_CLIENT_ID, "")
if not client_id and (ws_client_id := getattr(g, "ws_client_id", None)):
client_id = ws_client_id
if not client_id and force:
res = self._bindings()._get_or_create_scope("")
client_id = res[0] if res[1] else None
Expand Down Expand Up @@ -584,10 +587,12 @@ def _manage_message(self, msg_type: _WsType, message: dict) -> None:
if msg_type == _WsType.CLIENT_ID.value:
res = self._bindings()._get_or_create_scope(message.get("payload", ""))
client_id = res[0] if res[1] else None
self.__set_client_id_in_context(client_id or message.get(Gui.__ARG_CLIENT_ID))
expected_client_id = client_id or message.get(Gui.__ARG_CLIENT_ID)
self.__set_client_id_in_context(expected_client_id)
g.ws_client_id = expected_client_id
with self._set_locals_context(message.get("module_context") or None):
payload = message.get("payload", {})
if msg_type == _WsType.UPDATE.value:
payload = message.get("payload", {})
self.__front_end_update(
str(message.get("name")),
payload.get("value"),
Expand All @@ -601,6 +606,10 @@ def _manage_message(self, msg_type: _WsType, message: dict) -> None:
self.__request_data_update(str(message.get("name")), message.get("payload"))
elif msg_type == _WsType.REQUEST_UPDATE.value:
self.__request_var_update(message.get("payload"))
elif msg_type == _WsType.GET_MODULE_CONTEXT.value:
self.__handle_ws_get_module_context(payload)
elif msg_type == _WsType.GET_VARIABLES.value:
self.__handle_ws_get_variables()
self.__send_ack(message.get("ack_id"))
except Exception as e: # pragma: no cover
_warn(f"Decoding Message has failed: {message}", e)
Expand Down Expand Up @@ -1025,6 +1034,54 @@ def __request_var_update(self, payload: t.Any):
)
self.__send_var_list_update(payload["names"])

def __handle_ws_get_module_context(self, payload: t.Any):
if isinstance(payload, dict):
# Get Module Context
if mc := self._get_page_context(str(payload.get("path"))):
self._bind_custom_page_variables(
self._get_page(str(payload.get("path")))._renderer, self._get_client_id()
)
self.__send_ws(
{
"type": _WsType.GET_MODULE_CONTEXT.value,
"payload": {"data": mc},
}
)

def __handle_ws_get_variables(self):
# Get Variables
self.__pre_render_pages()
# Module Context -> Variable -> Variable data (name, type, initial_value)
variable_tree: t.Dict[str, t.Dict[str, t.Dict[str, t.Any]]] = {}
data = vars(self._bindings()._get_data_scope())
data = {
k: v
for k, v in data.items()
if not k.startswith("_")
and not callable(v)
and "TpExPr" not in k
and not isinstance(v, (ModuleType, FunctionType, LambdaType, type, Page))
}
for k, v in data.items():
if isinstance(v, _TaipyBase):
data[k] = v.get()
var_name, var_module_name = _variable_decode(k)
if var_module_name == "" or var_module_name is None:
var_module_name = "__main__"
if var_module_name not in variable_tree:
variable_tree[var_module_name] = {}
variable_tree[var_module_name][var_name] = {
"type": type(v).__name__,
"value": data[k],
"encoded_name": k,
}
self.__send_ws(
{
"type": _WsType.GET_VARIABLES.value,
"payload": {"data": variable_tree},
}
)

def __send_ws(self, payload: dict, allow_grouping=True) -> None:
grouping_message = self.__get_message_grouping() if allow_grouping else None
if grouping_message is None:
Expand Down Expand Up @@ -1866,10 +1923,12 @@ def __pre_render_pages(self) -> None:
for page in self._config.pages:
if page is not None:
with contextlib.suppress(Exception):
page.render(self)
if isinstance(page._renderer, CustomPage):
self._bind_custom_page_variables(page._renderer, self._get_client_id())
else:
page.render(self)

def __render_page(self, page_name: str) -> t.Any:
self.__set_client_id_in_context()
def _get_navigated_page(self, page_name: str) -> t.Any:
nav_page = page_name
if hasattr(self, "on_navigate") and callable(self.on_navigate):
try:
Expand All @@ -1890,8 +1949,26 @@ def __render_page(self, page_name: str) -> t.Any:
except Exception as e: # pragma: no cover
if not self._call_on_exception("on_navigate", e):
_warn("Exception raised in on_navigate()", e)
page = next((page_i for page_i in self._config.pages if page_i._route == nav_page), None)
return nav_page

def _get_page(self, page_name: str):
return next((page_i for page_i in self._config.pages if page_i._route == page_name), None)

def _bind_custom_page_variables(self, page: CustomPage, client_id: t.Optional[str]):
"""Handle the bindings of custom page variables"""
with self.get_flask_app().app_context() if has_app_context() else contextlib.nullcontext():
self.__set_client_id_in_context(client_id)
with self._set_locals_context(page._get_module_name()):
for k in self._get_locals_bind().keys():
if (not page._binding_variables or k in page._binding_variables) and not k.startswith("_"):
self._bind_var(k)

def __render_page(self, page_name: str) -> t.Any:
self.__set_client_id_in_context()
nav_page = self._get_navigated_page(page_name)
if not isinstance(nav_page, str):
return nav_page
page = self._get_page(nav_page)
# Try partials
if page is None:
page = self._get_partial(nav_page)
Expand All @@ -1902,6 +1979,19 @@ def __render_page(self, page_name: str) -> t.Any:
400,
{"Content-Type": "application/json; charset=utf-8"},
)
# Handle custom pages
if (pr := page._renderer) is not None and isinstance(pr, CustomPage):
if self._navigate(
to=page_name,
params={
_Server._RESOURCE_HANDLER_ARG: pr._resource_handler.get_id(),
},
):
# proactively handle the bindings of custom page variables
self._bind_custom_page_variables(pr, self._get_client_id())
return ("Successfully redirect to external resource handler", 200)
return ("Failed to navigate to external resource handler", 500)
# Handle page rendering
context = page.render(self)
if (
nav_page == Gui.__root_page_name
Expand Down
4 changes: 4 additions & 0 deletions src/taipy/gui/page.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ class Page:
"""

def __init__(self, **kwargs) -> None:
from .external import CustomPage

self._class_module_name = ""
self._class_locals: t.Dict[str, t.Any] = {}
self._frame: t.Optional[FrameType] = None
Expand All @@ -42,6 +44,8 @@ def __init__(self, **kwargs) -> None:
self._frame = kwargs.get("frame")
elif self._renderer:
self._frame = self._renderer._frame
elif isinstance(self, CustomPage):
self._frame = t.cast(FrameType, t.cast(FrameType, inspect.stack()[2].frame))
elif len(inspect.stack()) < 4:
raise RuntimeError(f"Can't resolve module. Page '{type(self).__name__}' is not registered.")
else:
Expand Down
17 changes: 16 additions & 1 deletion src/taipy/gui/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,10 @@
import webbrowser
from importlib import util
from random import randint
from urllib.parse import parse_qsl, urlparse

import __main__
from flask import Blueprint, Flask, json, jsonify, render_template, send_from_directory
from flask import Blueprint, Flask, json, jsonify, render_template, request, send_from_directory
from flask_cors import CORS
from flask_socketio import SocketIO
from gitignore_parser import parse_gitignore
Expand All @@ -35,6 +36,7 @@

from ._renderers.json import _TaipyJsonProvider
from .config import ServerConfig
from .external._custom_page import _ExternalResourceHandlerManager
from .utils import _is_in_notebook, _is_port_open, _RuntimeManager

if t.TYPE_CHECKING:
Expand All @@ -46,6 +48,7 @@ class _Server:
__RE_CLOSING_CURLY = re.compile(r"(\})([^\"])")
__OPENING_CURLY = r"\1&#x7B;"
__CLOSING_CURLY = r"&#x7D;\2"
_RESOURCE_HANDLER_ARG = "tprh"

def __init__(
self,
Expand Down Expand Up @@ -145,6 +148,18 @@ def _get_default_blueprint(
@taipy_bp.route("/", defaults={"path": ""})
@taipy_bp.route("/<path:path>")
def my_index(path):
resource_handler_id = dict(parse_qsl(urlparse(request.referrer or "").query)).get(
_Server._RESOURCE_HANDLER_ARG
)
resource_handler_id = resource_handler_id or request.args.get(_Server._RESOURCE_HANDLER_ARG, None)
if resource_handler_id is not None:
resource_handler = _ExternalResourceHandlerManager().get(resource_handler_id)
if resource_handler is None:
return (f"Invalid value for query {_Server._RESOURCE_HANDLER_ARG}", 404)
try:
return resource_handler.get_resources(path)
except Exception:
raise RuntimeError("Can't get resources from external resource handler")
if path == "" or path == "index.html" or "." not in path:
try:
return render_template(
Expand Down
2 changes: 2 additions & 0 deletions src/taipy/gui/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ class _WsType(Enum):
DOWNLOAD_FILE = "DF"
PARTIAL = "PR"
ACKNOWLEDGEMENT = "ACK"
GET_MODULE_CONTEXT = "GMC"
GET_VARIABLES = "GVS"


NumberTypes = {"int", "int64", "float", "float64"}
Expand Down
Loading