Skip to content

Commit

Permalink
Improve doc formatting for better results in IDEs (#2442)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
FabienLelaquais authored Feb 9, 2025
1 parent a932f8c commit d6a78b6
Show file tree
Hide file tree
Showing 5 changed files with 153 additions and 52 deletions.
133 changes: 90 additions & 43 deletions taipy/gui/extension/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import argparse
import os
import re
import textwrap
import typing as t
from io import StringIO

Expand All @@ -15,15 +17,22 @@ def error(message):
exit(1)


I = " " # noqa: E741 - Indentation is 4 spaces


def generate_doc(library: ElementLibrary) -> str: # noqa: C901F
stream = StringIO()

def clean_doc_string(doc_string) -> t.Optional[str]:
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():
Expand All @@ -33,90 +42,119 @@ def clean_doc_string(doc_string) -> t.Optional[str]:
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)

Expand All @@ -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 changes: 4 additions & 0 deletions taipy/gui/extension/library.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def __init__(
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^`.
Expand All @@ -57,6 +58,8 @@ def __init__(
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]]
Expand All @@ -71,6 +74,7 @@ def __init__(
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):
Expand Down
11 changes: 11 additions & 0 deletions tests/gui/extension/extlib_test/__init__.py
Original file line number Diff line number Diff line change
@@ -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 changes: 21 additions & 0 deletions tests/gui/extension/extlib_test/library.py
Original file line number Diff line number Diff line change
@@ -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)})}
36 changes: 27 additions & 9 deletions tests/gui/extension/test_tgb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
),
Expand All @@ -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)

0 comments on commit d6a78b6

Please sign in to comment.