diff --git a/.github/workflows/overall-tests.yml b/.github/workflows/overall-tests.yml index 79486f3c70..10b518144d 100644 --- a/.github/workflows/overall-tests.yml +++ b/.github/workflows/overall-tests.yml @@ -18,7 +18,7 @@ jobs: uses: ./.github/workflows/partial-tests.yml coverage: - timeout-minutes: 40 + timeout-minutes: 50 runs-on: ubuntu-latest if: ${{ github.event_name == 'pull_request' }} steps: diff --git a/taipy/gui/builder/_api_generator.py b/taipy/gui/builder/_api_generator.py index 67331ef33f..79cb39ae88 100644 --- a/taipy/gui/builder/_api_generator.py +++ b/taipy/gui/builder/_api_generator.py @@ -87,7 +87,7 @@ def add_library(self, library: "ElementLibrary"): ) # Allow element to be accessed from the root module if hasattr(self.__module, element_name): - _TaipyLogger()._get_logger().info( + _TaipyLogger._get_logger().info( f"Can't add element `{element_name}` of library `{library_name}` to the root of Builder API as another element with the same name already exists." # noqa: E501 ) continue diff --git a/taipy/gui/gui.py b/taipy/gui/gui.py index 5e11514a62..3e382e11dc 100644 --- a/taipy/gui/gui.py +++ b/taipy/gui/gui.py @@ -66,6 +66,7 @@ from .data.data_format import _DataFormat from .data.data_scope import _DataScopes from .extension.library import Element, ElementLibrary +from .hook import Hooks from .page import Page from .partial import Partial from .server import _Server @@ -365,6 +366,9 @@ def __init__( ] ) + # Init Gui Hooks + Hooks()._init(self) + if page: self.add_page(name=Gui.__root_page_name, page=page) if pages is not None: @@ -603,10 +607,10 @@ def __clean_vars_on_exit(self) -> t.Optional[t.Set[str]]: return None def _handle_connect(self): - pass + Hooks().handle_connect(self) def _handle_disconnect(self): - pass + Hooks()._handle_disconnect(self) def _manage_message(self, msg_type: _WsType, message: dict) -> None: try: @@ -667,7 +671,7 @@ def _manage_message(self, msg_type: _WsType, message: dict) -> None: # To be expanded by inheriting classes # this will be used to handle ws messages that is not handled by the base Gui class def _manage_external_message(self, msg_type: _WsType, message: dict) -> None: - pass + Hooks()._manage_external_message(self, msg_type, message) def __front_end_update( self, @@ -1900,6 +1904,8 @@ def add_page( # set root page if name == Gui.__root_page_name: self._config.root_page = new_page + # Validate Page + Hooks().validate_page(self, page) # Update locals context self.__locals_context.add(page._get_module_name(), page._get_locals()) # Update variable directory @@ -1909,6 +1915,8 @@ def add_page( if _is_in_notebook(): page._notebook_gui = self page._notebook_page = new_page + # add page to hook + Hooks().add_page(self, page) def add_pages(self, pages: t.Optional[t.Union[t.Mapping[str, t.Union[str, Page]], str]] = None) -> None: """Add several pages to the Graphical User Interface. @@ -2366,12 +2374,16 @@ def _set_frame(self, frame: t.Optional[FrameType]): self.__default_module_name = _get_module_name_from_frame(self.__frame) def _set_css_file(self, css_file: t.Optional[str] = None): + script_file = Path(self.__frame.f_code.co_filename or ".").resolve() if css_file is None: - script_file = Path(self.__frame.f_code.co_filename or ".").resolve() if script_file.with_suffix(".css").exists(): css_file = f"{script_file.stem}.css" elif script_file.is_dir() and (script_file / "taipy.css").exists(): css_file = "taipy.css" + if css_file is None: + script_file = script_file.with_name("taipy").with_suffix(".css") + if script_file.exists(): + css_file = f"{script_file.stem}.css" self.__css_file = css_file def _set_state(self, state: State): @@ -2620,6 +2632,10 @@ def run( # # The default value is None. # -------------------------------------------------------------------------------- + + # setup run function with gui hooks + Hooks().run(self, **kwargs) + app_config = self._config.config run_root_dir = os.path.dirname(inspect.getabsfile(self.__frame)) diff --git a/taipy/gui/hook.py b/taipy/gui/hook.py new file mode 100644 index 0000000000..f04d0dd96c --- /dev/null +++ b/taipy/gui/hook.py @@ -0,0 +1,45 @@ +import typing as t + +from taipy.logger._taipy_logger import _TaipyLogger + +from .utils.singleton import _Singleton + + +class Hook: + method_names: t.List[str] = [] + + +class Hooks(object, metaclass=_Singleton): + def __init__(self): + self.__hooks: t.List[Hook] = [] + + def _register_hook(self, hook: Hook): + # Prevent duplicated hooks + for h in self.__hooks: + if type(hook) is type(h): + _TaipyLogger._get_logger().info(f"Failed to register duplicated hook of type '{type(h)}'") + return + self.__hooks.append(hook) + + def __getattr__(self, name: str): + def _resolve_hook(*args, **kwargs): + for hook in self.__hooks: + if name not in hook.method_names: + continue + # call hook + try: + func = getattr(hook, name) + if not callable(func): + raise Exception(f"'{name}' hook is not callable") + res = getattr(hook, name)(*args, **kwargs) + except Exception as e: + _TaipyLogger._get_logger().error(f"Error while calling hook '{name}': {e}") + return + # check if the hook returns True -> stop the chain + if res is True: + return + # only hooks that return true are allowed to return values to ensure consistent response + if isinstance(res, (list, tuple)) and len(res) == 2 and res[0] is True: + return res[1] + + return _resolve_hook diff --git a/taipy/gui/state.py b/taipy/gui/state.py index e8902c2b76..b60122e227 100644 --- a/taipy/gui/state.py +++ b/taipy/gui/state.py @@ -128,7 +128,7 @@ def __getattribute__(self, name: str) -> t.Any: name not in gui._get_shared_variables() and not gui._bindings()._is_single_client() ): raise AttributeError(f"Variable '{name}' is not available to be accessed in shared callback.") - if name not in super().__getattribute__(State.__attrs[1]): + if not name.startswith("__") and name not in super().__getattribute__(State.__attrs[1]): raise AttributeError(f"Variable '{name}' is not defined.") with self._notebook_context(gui), self._set_context(gui): encoded_name = gui._bind_var(name) @@ -140,7 +140,7 @@ def __setattr__(self, name: str, value: t.Any) -> None: name not in gui._get_shared_variables() and not gui._bindings()._is_single_client() ): raise AttributeError(f"Variable '{name}' is not available to be accessed in shared callback.") - if name not in super().__getattribute__(State.__attrs[1]): + if not name.startswith("__") and name not in super().__getattribute__(State.__attrs[1]): raise AttributeError(f"Variable '{name}' is not accessible.") with self._notebook_context(gui), self._set_context(gui): encoded_name = gui._bind_var(name) diff --git a/taipy/gui/viselements.json b/taipy/gui/viselements.json index a8a3f69793..b423957199 100644 --- a/taipy/gui/viselements.json +++ b/taipy/gui/viselements.json @@ -1264,7 +1264,7 @@ "name": "value", "type": "dynamic(int)", "doc": "If set, then the value represents the progress percentage that is shown.TODO - if unset?", - "default_property": "true" + "default_property": true }, { "name": "linear", @@ -1281,8 +1281,8 @@ { "name": "render", "type": "dynamic(bool)", - "doc": "If False, this progress indicator is hidden from the page.", - "default_property": "true" + "default_value": "True", + "doc": "If False, this progress indicator is hidden from the page." } ] } diff --git a/taipy/gui_core/_context.py b/taipy/gui_core/_context.py index 4b3df47ad6..4c787168b9 100644 --- a/taipy/gui_core/_context.py +++ b/taipy/gui_core/_context.py @@ -94,11 +94,19 @@ def __init__(self, gui: Gui) -> None: # locks self.lock = Lock() self.submissions_lock = Lock() + # lazy_start + self.__started = False # super super().__init__(reg_id, reg_queue) + + def __lazy_start(self): + if self.__started: + return + self.__started = True self.start() def process_event(self, event: Event): + self.__lazy_start() if event.entity_type == EventEntityType.SCENARIO: with self.gui._get_autorization(system=True): self.scenario_refresh( @@ -221,6 +229,7 @@ def no_change_adapter(self, entity: t.List): return entity def cycle_adapter(self, cycle: Cycle, sorts: t.Optional[t.List[t.Dict[str, t.Any]]] = None): + self.__lazy_start() try: if ( isinstance(cycle, Cycle) @@ -243,6 +252,7 @@ def cycle_adapter(self, cycle: Cycle, sorts: t.Optional[t.List[t.Dict[str, t.Any return None def scenario_adapter(self, scenario: Scenario): + self.__lazy_start() if isinstance(scenario, (tuple, list)): return scenario try: @@ -342,6 +352,7 @@ def get_scenarios( filters: t.Optional[t.List[t.Dict[str, t.Any]]], sorts: t.Optional[t.List[t.Dict[str, t.Any]]], ): + self.__lazy_start() cycles_scenarios: t.List[t.Union[Cycle, Scenario]] = [] with self.lock: # always needed to get scenarios for a cycle in cycle_adapter @@ -360,12 +371,14 @@ def get_scenarios( return adapted_list def select_scenario(self, state: State, id: str, payload: t.Dict[str, str]): + self.__lazy_start() args = payload.get("args") if args is None or not isinstance(args, list) or len(args) < 2: return state.assign(args[0], args[1]) def get_scenario_by_id(self, id: str) -> t.Optional[Scenario]: + self.__lazy_start() if not id or not is_readable(t.cast(ScenarioId, id)): return None try: @@ -374,6 +387,7 @@ def get_scenario_by_id(self, id: str) -> t.Optional[Scenario]: return None def get_scenario_configs(self): + self.__lazy_start() with self.lock: if self.scenario_configs is None: configs = Config.scenarios @@ -382,6 +396,7 @@ def get_scenario_configs(self): return self.scenario_configs def crud_scenario(self, state: State, id: str, payload: t.Dict[str, str]): # noqa: C901 + self.__lazy_start() args = payload.get("args") start_idx = 2 if ( @@ -519,6 +534,7 @@ def __assign_var(state: State, var_name: t.Optional[str], msg: str): state.assign(var_name, msg) def edit_entity(self, state: State, id: str, payload: t.Dict[str, str]): + self.__lazy_start() args = payload.get("args") if args is None or not isinstance(args, list) or len(args) < 1 or not isinstance(args[0], dict): return @@ -567,6 +583,7 @@ def edit_entity(self, state: State, id: str, payload: t.Dict[str, str]): _GuiCoreContext.__assign_var(state, error_var, f"Error updating {type(scenario).__name__}. {e}") def submit_entity(self, state: State, id: str, payload: t.Dict[str, str]): + self.__lazy_start() args = payload.get("args") if args is None or not isinstance(args, list) or len(args) < 1 or not isinstance(args[0], dict): return @@ -679,6 +696,7 @@ def get_datanodes_tree( filters: t.Optional[t.List[t.Dict[str, t.Any]]], sorts: t.Optional[t.List[t.Dict[str, t.Any]]], ): + self.__lazy_start() base_list = [] with self.lock: self.__do_datanodes_tree() @@ -707,6 +725,7 @@ def data_node_adapter( sorts: t.Optional[t.List[t.Dict[str, t.Any]]] = None, adapt_dn=True, ): + self.__lazy_start() if isinstance(data, tuple): raise NotImplementedError if isinstance(data, list): @@ -767,12 +786,14 @@ def data_node_adapter( return None def get_jobs_list(self): + self.__lazy_start() with self.lock: if self.jobs_list is None: self.jobs_list = get_jobs() return self.jobs_list def job_adapter(self, job): + self.__lazy_start() try: if hasattr(job, "id") and is_readable(job.id) and core_get(job.id) is not None: if isinstance(job, Job): @@ -795,6 +816,7 @@ def job_adapter(self, job): return None def act_on_jobs(self, state: State, id: str, payload: t.Dict[str, str]): + self.__lazy_start() args = payload.get("args") if args is None or not isinstance(args, list) or len(args) < 1 or not isinstance(args[0], dict): return @@ -830,6 +852,7 @@ def act_on_jobs(self, state: State, id: str, payload: t.Dict[str, str]): _GuiCoreContext.__assign_var(state, payload.get("error_id"), "
".join(errs) if errs else "") def edit_data_node(self, state: State, id: str, payload: t.Dict[str, str]): + self.__lazy_start() args = payload.get("args") if args is None or not isinstance(args, list) or len(args) < 1 or not isinstance(args[0], dict): return @@ -847,6 +870,7 @@ def edit_data_node(self, state: State, id: str, payload: t.Dict[str, str]): _GuiCoreContext.__assign_var(state, error_var, f"Error updating Datanode. {e}") def lock_datanode_for_edit(self, state: State, id: str, payload: t.Dict[str, str]): + self.__lazy_start() args = payload.get("args") if args is None or not isinstance(args, list) or len(args) < 1 or not isinstance(args[0], dict): return @@ -893,6 +917,7 @@ def __edit_properties(self, entity: t.Union[Scenario, Sequence, DataNode], data: ent.properties.pop(key, None) def get_scenarios_for_owner(self, owner_id: str): + self.__lazy_start() cycles_scenarios: t.List[t.Union[Scenario, Cycle]] = [] with self.lock: if self.scenario_by_cycle is None: @@ -913,6 +938,7 @@ def get_scenarios_for_owner(self, owner_id: str): return sorted(cycles_scenarios, key=_get_entity_property("creation_date", Scenario)) def get_data_node_history(self, id: str): + self.__lazy_start() if id and (dn := core_get(id)) and isinstance(dn, DataNode): res = [] for e in dn.edits: @@ -945,6 +971,7 @@ def __check_readable_editable(self, state: State, id: str, ent_type: str, var: t return True def update_data(self, state: State, id: str, payload: t.Dict[str, str]): + self.__lazy_start() args = payload.get("args") if args is None or not isinstance(args, list) or len(args) < 1 or not isinstance(args[0], dict): return @@ -973,6 +1000,7 @@ def update_data(self, state: State, id: str, payload: t.Dict[str, str]): _GuiCoreContext.__assign_var(state, payload.get("data_id"), entity_id) # this will update the data value def tabular_data_edit(self, state: State, var_name: str, payload: dict): + self.__lazy_start() error_var = payload.get("error_id") user_data = payload.get("user_data", {}) dn_id = user_data.get("dn_id") @@ -1039,6 +1067,7 @@ def tabular_data_edit(self, state: State, var_name: str, payload: dict): _GuiCoreContext.__assign_var(state, payload.get("data_id"), dn_id) def get_data_node_properties(self, id: str): + self.__lazy_start() if id and is_readable(t.cast(DataNodeId, id)) and (dn := core_get(id)) and isinstance(dn, DataNode): try: return ( @@ -1056,6 +1085,7 @@ def __read_tabular_data(self, datanode: DataNode): return datanode.read() def get_data_node_tabular_data(self, id: str): + self.__lazy_start() if ( id and is_readable(t.cast(DataNodeId, id)) @@ -1072,6 +1102,7 @@ def get_data_node_tabular_data(self, id: str): return None def get_data_node_tabular_columns(self, id: str): + self.__lazy_start() if ( id and is_readable(t.cast(DataNodeId, id)) @@ -1090,6 +1121,7 @@ def get_data_node_tabular_columns(self, id: str): return None def get_data_node_chart_config(self, id: str): + self.__lazy_start() if ( id and is_readable(t.cast(DataNodeId, id)) @@ -1106,6 +1138,7 @@ def get_data_node_chart_config(self, id: str): return None def on_dag_select(self, state: State, id: str, payload: t.Dict[str, str]): + self.__lazy_start() args = payload.get("args") if args is None or not isinstance(args, list) or len(args) < 2: return @@ -1124,4 +1157,5 @@ def on_dag_select(self, state: State, id: str, payload: t.Dict[str, str]): _warn(f"dag.on_action(): Invalid function '{args[1]}()'.") def get_creation_reason(self): + self.__lazy_start() return "" if (reason := can_create()) else f"Cannot create scenario: {reason.reasons}"