فهرست منبع

Merge branch 'develop' of github.com:Avaiga/taipy into test/Table

namnguyen 9 ماه پیش
والد
کامیت
1695087065

+ 1 - 1
.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:

+ 8 - 5
taipy/_cli/_run_cli.py

@@ -54,10 +54,13 @@ class _RunCLI(_AbstractCLI):
 
         taipy_args = [f"--taipy-{arg[2:]}" if arg.startswith("--") else arg for arg in all_args]
 
-        subprocess.run(
-            [sys.executable, args.application_main_file, *(external_args + taipy_args)],
-            stdout=sys.stdout,
-            stderr=sys.stdout,
-        )
+        try:
+            subprocess.run(
+                [sys.executable, args.application_main_file, *(external_args + taipy_args)],
+                stdout=sys.stdout,
+                stderr=sys.stdout,
+            )
+        except KeyboardInterrupt:
+            pass
 
         sys.exit(0)

+ 1 - 1
taipy/gui/builder/_api_generator.py

@@ -87,7 +87,7 @@ class _ElementApiGenerator(object, metaclass=_Singleton):
             )
             # 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

+ 20 - 4
taipy/gui/gui.py

@@ -66,6 +66,7 @@ from .data.data_accessor import _DataAccessors
 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 @@ class Gui:
             ]
         )
 
+        # 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 @@ class Gui:
             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 @@ class Gui:
     # 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 @@ class Gui:
         # 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 @@ class Gui:
         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 @@ class Gui:
         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 @@ class Gui:
         #
         #         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))

+ 45 - 0
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

+ 6 - 2
taipy/gui/server.py

@@ -256,8 +256,9 @@ class _Server:
 
     def _apply_patch(self):
         if self._get_async_mode() == "gevent" and util.find_spec("gevent"):
-            from gevent import monkey
+            from gevent import get_hub, monkey
 
+            get_hub().NOT_ERROR += (KeyboardInterrupt, )
             if not monkey.is_module_patched("time"):
                 monkey.patch_time()
         if self._get_async_mode() == "eventlet" and util.find_spec("eventlet"):
@@ -318,7 +319,10 @@ class _Server:
         # flask-socketio specific conditions for 'allow_unsafe_werkzeug' parameters to be popped out of kwargs
         if self._get_async_mode() == "threading" and (not sys.stdin or not sys.stdin.isatty()):
             run_config = {**run_config, "allow_unsafe_werkzeug": allow_unsafe_werkzeug}
-        self._ws.run(**run_config)
+        try:
+            self._ws.run(**run_config)
+        except KeyboardInterrupt:
+            pass
 
     def stop_thread(self):
         if hasattr(self, "_thread") and self._thread.is_alive() and self._is_running:

+ 2 - 2
taipy/gui/state.py

@@ -128,7 +128,7 @@ class State:
             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 @@ class State:
             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)

+ 3 - 3
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."
                     }
                 ]
             }

+ 34 - 0
taipy/gui_core/_context.py

@@ -94,11 +94,19 @@ class _GuiCoreContext(CoreEventConsumerBase):
         # 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 @@ class _GuiCoreContext(CoreEventConsumerBase):
         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 @@ class _GuiCoreContext(CoreEventConsumerBase):
         return None
 
     def scenario_adapter(self, scenario: Scenario):
+        self.__lazy_start()
         if isinstance(scenario, (tuple, list)):
             return scenario
         try:
@@ -342,6 +352,7 @@ class _GuiCoreContext(CoreEventConsumerBase):
         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 @@ class _GuiCoreContext(CoreEventConsumerBase):
         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 @@ class _GuiCoreContext(CoreEventConsumerBase):
             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 @@ class _GuiCoreContext(CoreEventConsumerBase):
             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 @@ class _GuiCoreContext(CoreEventConsumerBase):
             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 @@ class _GuiCoreContext(CoreEventConsumerBase):
                 _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 @@ class _GuiCoreContext(CoreEventConsumerBase):
         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 @@ class _GuiCoreContext(CoreEventConsumerBase):
         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 @@ class _GuiCoreContext(CoreEventConsumerBase):
         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 @@ class _GuiCoreContext(CoreEventConsumerBase):
         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 @@ class _GuiCoreContext(CoreEventConsumerBase):
             _GuiCoreContext.__assign_var(state, payload.get("error_id"), "<br/>".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 @@ class _GuiCoreContext(CoreEventConsumerBase):
                 _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 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         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 @@ class _GuiCoreContext(CoreEventConsumerBase):
         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 @@ class _GuiCoreContext(CoreEventConsumerBase):
         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 @@ class _GuiCoreContext(CoreEventConsumerBase):
             _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 @@ class _GuiCoreContext(CoreEventConsumerBase):
         _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 @@ class _GuiCoreContext(CoreEventConsumerBase):
         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 @@ class _GuiCoreContext(CoreEventConsumerBase):
         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 @@ class _GuiCoreContext(CoreEventConsumerBase):
         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 @@ class _GuiCoreContext(CoreEventConsumerBase):
         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 @@ class _GuiCoreContext(CoreEventConsumerBase):
             _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}"