Преглед изворни кода

Improve doc formatting for better results in IDEs (#2442)

* Improve doc formatting for better results in IDEs
* Handle default property values
* Hide some code from coverage
* Added type_hint in extension library properties.
* Added test for extension library properties type hints.
Fabien Lelaquais пре 3 месеци
родитељ
комит
d6a78b6e4b

+ 90 - 43
taipy/gui/extension/__main__.py

@@ -4,6 +4,8 @@
 
 import argparse
 import os
+import re
+import textwrap
 import typing as t
 from io import StringIO
 
@@ -15,6 +17,9 @@ def error(message):
     exit(1)
 
 
+I = "    "  # noqa: E741 - Indentation is 4 spaces
+
+
 def generate_doc(library: ElementLibrary) -> str:  # noqa: C901F
     stream = StringIO()
 
@@ -22,8 +27,12 @@ def generate_doc(library: ElementLibrary) -> str:  # noqa: C901F
         if not doc_string:
             return None
         lines = doc_string.splitlines()
-        min_indent = min((len(line) - len(line.lstrip())) for line in lines if line.strip())
-        lines = [line[min_indent:] if line.strip() else "" for line in lines]
+        first_line = lines.pop(0) if len(lines[0]) == len(lines[0].lstrip()) else None
+        if lines:
+            min_indent = min((len(line) - len(line.lstrip())) for line in lines if line.strip())
+            lines = [line[min_indent:] if line.strip() else "" for line in lines]
+        if first_line:
+            lines.insert(0, first_line)
         while lines and not lines[0].strip():
             lines.pop(0)
         while lines and not lines[-1].strip():
@@ -33,90 +42,119 @@ def generate_doc(library: ElementLibrary) -> str:  # noqa: C901F
     print("# ----------------------------------------------------------------------", file=stream)
     print("# Generated by taipy.gui.extension module", file=stream)
     print("# ----------------------------------------------------------------------", file=stream)
+    print("import typing as t", file=stream)
+    print("", file=stream)
+    first_element = True
     for element_name, element in library.get_elements().items():
-        properties: list[str] = []
+        if first_element:
+            first_element = False
+        else:
+            print("\n", file=stream)
+        property_names: list[str] = []
+        parameters: list[str] = []
         property_doc = {}
         default_property_found = False
         for property_name, property in element.attributes.items():
-            desc = property_name
-            # Could use 'match' with Python >= 3.10
-            if property.property_type in [PropertyType.boolean, PropertyType.dynamic_boolean]:
-                desc = desc + ": bool"
-            elif property.property_type in [PropertyType.string, PropertyType.dynamic_string]:
-                desc = desc + ": str"
-            elif property.property_type in [PropertyType.dict, PropertyType.dynamic_dict]:
-                desc = desc + ": dict"
+            property_names.append(property_name)
+            prop_def_value = property.default_value
+            prop_type = property.type_hint
+            if prop_type:
+                prop_type = re.sub(r"\b(?<!t\.)\b(Optional|Union)\[", r"t.\1[", prop_type)
+            else:
+                # Could use 'match' with Python >= 3.10
+                prop_type = "t.Union[str, any]"
+                if property.property_type in [PropertyType.boolean, PropertyType.dynamic_boolean]:
+                    prop_type = "t.Union[bool, str]"
+                elif property.property_type in [PropertyType.string, PropertyType.dynamic_string]:
+                    prop_type = "str"
+                    if prop_def_value:
+                        prop_def_value = f'"{str(prop_def_value)}"'
+                elif property.property_type in [PropertyType.dict, PropertyType.dynamic_dict]:
+                    prop_type = "t.Union[dict, str]"
+                    if prop_def_value:
+                        prop_def_value = f'"{str(prop_def_value)}"'
+                if prop_def_value is None:
+                    prop_def_value = "None"
+                prop_type = f"t.Optional[{prop_type}]"
+            desc = f"{property_name}: {prop_type} = {prop_def_value}"
             if property_name == element.default_attribute:
-                properties.insert(0, desc)
+                parameters.insert(0, desc)
                 default_property_found = True
             else:
-                properties.append(desc)
+                parameters.append(desc)
             if doc_string := clean_doc_string(property.doc_string):
                 property_doc[property_name] = doc_string
-        if default_property_found and len(properties) > 1:
-            properties.insert(1, "*")
+        if default_property_found and len(parameters) > 1:
+            parameters.insert(1, "*")
         doc_string = clean_doc_string(element.doc_string)
         documentation = ""
         if doc_string:
             lines = doc_string.splitlines()
-            documentation = f'    """{lines.pop(0)}\n'
+            documentation = f'{I}"""{lines.pop(0)}\n'
             while lines:
                 line = lines.pop(0)
-                documentation += f"    {line}\n" if line else "\n"
+                documentation += f"{I}{line}\n" if line else "\n"
         if property_doc:
-            documentation += "\n    Arguments:\n"
-            for property_name, doc_string in property_doc.items():
-                lines = doc_string.splitlines()
-                documentation += f"        {property_name}: {lines.pop(0)}\n"
-                while lines:
-                    line = lines.pop(0)
-                    if line:
-                        documentation += f"        {line}\n"
+            documentation += f"\n{I}### Parameters:\n"
+            for property_name in property_names:
+                doc_string = property_doc.get(property_name)
+                if doc_string:
+                    lines = doc_string.splitlines()
+                    documentation += f"{I}`{property_name}`: {lines.pop(0)}\n"
+                    for line in lines:
+                        documentation += f"{I * 2}{line}\n" if line else "\n"
+                else:
+                    documentation += f"{I}`{property_name}`: ...\n"
+                documentation += "\n"
         if documentation:
-            documentation += '    """\n'
-        print(f"def {element_name}({', '.join(properties)}):\n{documentation}    ...\n\n", file=stream)
+            documentation += f'{I}"""\n'
+        parameters_list = ",\n".join([f"{I}{p}" for p in parameters])
+        print(f"def {element_name}(\n{parameters_list},\n):\n{documentation}{I}...", file=stream)
 
     return stream.getvalue()
 
 
-def generate_tgb(args) -> None:
+def generate_tgb(args) -> int:
     from importlib import import_module
     from inspect import getmembers, isclass
 
     package_root_dir = args.package_root_dir[0]
     # Remove potential directory separator at the end of the package root dir
-    if package_root_dir[-1] == "/" or package_root_dir[-1] == "\\":
+    if package_root_dir[-1] == "/" or package_root_dir[-1] == "\\":  # pragma: no cover
         package_root_dir = package_root_dir[:-1]
     module = None
     try:
         module = import_module(package_root_dir)
-    except Exception as e:
+    except Exception as e:  # pragma: no cover
         error(f"Couldn't open module '{package_root_dir}' ({e})")
     library: t.Optional[ElementLibrary] = None
     for _, member in getmembers(module, lambda o: isclass(o) and issubclass(o, ElementLibrary)):
-        if library:
+        if library:  # pragma: no cover
             error("Extension contains more than one ElementLibrary")
         library = member()
-    if library is None:
+    if library is None:  # pragma: no cover
         error("Extension does not contain any ElementLibrary")
-        return  # To avoid having to deal with this case in the following code
-    pyi_path = os.path.join(package_root_dir, "__init__.pyi")
+        return 1  # To avoid having to deal with this case in the following code
+
+    if (pyi_path := args.output_path) is None:  # pragma: no cover
+        pyi_path = os.path.join(package_root_dir, "__init__.pyi")
     pyi_file = None
     try:
         pyi_file = open(pyi_path, "w")
-    except Exception as e:
-        error(f"Couldn't open Python Interface Definition file '{pyi_file}' ({e})")
+    except Exception as e:  # pragma: no cover
+        error(f"Couldn't open Python Interface Definition file '{pyi_path}' ({e})")
 
     print(f"Inspecting extension library '{library.get_name()}'")  # noqa: T201
     content = generate_doc(library)
 
     if pyi_file:
-        print(content, file=pyi_file)
+        print(content, file=pyi_file, end="")
         pyi_file.close()
     print(f"File '{pyi_path}' was updated.")  # noqa: T201
+    return 0
 
 
-def main(arg_strings=None) -> None:
+def main(argv=None) -> int:
     parser = argparse.ArgumentParser(description="taipy.gui.extensions entry point.")
     sub_parser = parser.add_subparsers(dest="command", help="Commands to run", required=True)
 
@@ -126,13 +164,22 @@ def main(arg_strings=None) -> None:
     tgb_generation.add_argument(
         dest="package_root_dir",
         nargs=1,
-        help="The root dir of the extension package." + " This directory must contain a __init__.py file.",
+        help=textwrap.dedent("""\
+                             The root dir of the extension package.
+                             This directory must contain a __init__.py file."""),
+    )
+    tgb_generation.add_argument(
+        dest="output_path",
+        nargs="?",
+        help=textwrap.dedent("""\
+                             The output path for the Python Interface Definition file.
+                             The default is a file called '__init__.pyi' in the module's root directory."""),
     )
     tgb_generation.set_defaults(func=generate_tgb)
 
-    args = parser.parse_args(arg_strings)
-    args.func(args)
+    args = parser.parse_args(argv)
+    return args.func(args)
 
 
-if __name__ == "__main__":
-    main()
+if __name__ == "__main__":  # pragma: no cover
+    exit(main())

+ 4 - 0
taipy/gui/extension/library.py

@@ -45,6 +45,7 @@ class ElementProperty:
         with_update: t.Optional[bool] = None,
         *,
         doc_string: t.Optional[str] = None,
+        type_hint: t.Optional[str] = None,
     ) -> None:
         """Initializes a new custom property declaration for an `Element^`.
 
@@ -57,6 +58,8 @@ class ElementProperty:
                 JavaScript code.
             doc_string: An optional string that holds documentation for that property.<br/>
                 This is used when generating the stub classes for extension libraries.
+            type_hint: An optional string describing the Python type of that property.<br/>
+                This is used when generating the stub classes for extension libraries.
         """
         self.default_value = default_value
         self.property_type: t.Union[PropertyType, t.Type[_TaipyBase]]
@@ -71,6 +74,7 @@ class ElementProperty:
         self._js_name = js_name
         self.with_update = with_update
         self.doc_string = doc_string
+        self.type_hint = type_hint
         super().__init__()
 
     def check(self, element_name: str, prop_name: str):

+ 11 - 0
tests/gui/extension/extlib_test/__init__.py

@@ -0,0 +1,11 @@
+# Copyright 2021-2025 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 .library import Library

+ 21 - 0
tests/gui/extension/extlib_test/library.py

@@ -0,0 +1,21 @@
+# Copyright 2021-2025 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 taipy.gui.extension import Element, ElementLibrary, ElementProperty, PropertyType
+
+
+class Library(ElementLibrary):
+    """Test Extension Library"""
+
+    def get_name(self) -> str:
+        return "test"
+
+    def get_elements(self) -> dict:
+        return {"test": Element("test", {"test": ElementProperty(PropertyType.string)})}

+ 27 - 9
tests/gui/extension/test_tgb.py

@@ -8,7 +8,9 @@
 # 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.
+import re
 import typing as t
+from unittest.mock import patch
 
 from taipy.gui import Gui
 from taipy.gui.extension import Element, ElementLibrary, ElementProperty, PropertyType
@@ -26,13 +28,15 @@ class TgbLibrary(ElementLibrary):
                 "d1": ElementProperty(PropertyType.dict),
                 "d2": ElementProperty(PropertyType.dynamic_dict),
             },
-            "E1", doc_string="e1 doc",
+            "E1",
+            doc_string="e1 doc",
         ),
         "e2": Element(
             "x",
             {
                 "p1": ElementProperty(PropertyType.any),
                 "p2": ElementProperty(PropertyType.any),
+                "p3": ElementProperty(PropertyType.any, type_hint="Union[bool,str]"),
             },
             "E2",
         ),
@@ -52,13 +56,27 @@ def test_tgb_generation(gui: Gui, test_client, helpers):
     api = generate_doc(library)
     assert "def e1(" in api, "Missing element e1"
     assert "s1" in api, "Missing property s1"
-    assert "s1: str" in api, "Incorrect property type for s1"
-    assert "(s1: str, *" in api, "Property s1 should be the default property"
-    assert "b1: bool" in api, "Missing or incorrect property type for b1"
-    assert "b2: bool" in api, "Missing or incorrect property type for b2"
-    assert "s2: str" in api, "Missing or incorrect property type for s2"
-    assert "d1: dict" in api, "Missing or incorrect property type for d2"
-    assert "d2: dict" in api, "Missing or incorrect property type for d2"
+    assert re.search(r"\(\s*s1\s*:", api), "Property s1 should be the default property"
+    assert re.search(r"b1:\s*t.Optional\[t.Union\[bool", api), "Incorrect property type for b1"
+    assert re.search(r"b2:\s*t.Optional\[t.Union\[bool", api), "Incorrect property type for b2"
+    assert re.search(r"s1:\s*t.Optional\[str\]", api), "Incorrect property type for s1"
+    assert re.search(r"s2:\s*t.Optional\[str\]", api), "Incorrect property type for s2"
+    assert re.search(r"d1:\s*t.Optional\[t.Union\[dict", api), "Incorrect property type for d1"
+    assert re.search(r"d2:\s*t.Optional\[t.Union\[dict", api), "Incorrect property type for d2"
     assert "e1 doc" in api, "Missing doc for e1"
     assert "def e2(" in api, "Missing element e2"
-    assert "e2(p1, p2)" in api, "Wrong default property in e2"
+    assert re.search(r"\(\s*p1\s*:", api), "Wrong default property in e2"
+    assert re.search(r"p3:\s*t\.Union", api), "Wrong type hint for property p3 in e2"
+
+
+def test_tgb_generation_entry_point(gui: Gui, test_client, helpers):
+    import os
+    import tempfile
+
+    from taipy.gui.extension.__main__ import main
+
+    temp_file = tempfile.NamedTemporaryFile(delete=False)
+    temp_file.close()
+    with patch("sys.argv", ["main", "generate_tgb", "extlib_test", temp_file.name]):
+        assert main() == 0
+    os.remove(temp_file.name)