documentation_tools.py 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  1. import importlib
  2. import inspect
  3. import re
  4. from pathlib import Path
  5. from typing import Callable, Optional, Union
  6. import docutils.core
  7. from nicegui import globals, ui
  8. from nicegui.elements.markdown import apply_tailwind
  9. from .demo import demo
  10. SPECIAL_CHARACTERS = re.compile('[^(a-z)(A-Z)(0-9)-]')
  11. def remove_indentation(text: str) -> str:
  12. """Remove indentation from a multi-line string based on the indentation of the first line."""
  13. lines = text.splitlines()
  14. while lines and not lines[0].strip():
  15. lines.pop(0)
  16. if not lines:
  17. return ''
  18. indentation = len(lines[0]) - len(lines[0].lstrip())
  19. return '\n'.join(line[indentation:] for line in lines)
  20. def pascal_to_snake(name: str) -> str:
  21. return re.sub(r'(?<!^)(?=[A-Z])', '_', name).lower()
  22. def create_anchor_name(text: str) -> str:
  23. return SPECIAL_CHARACTERS.sub('_', text).lower()
  24. def get_menu() -> ui.left_drawer:
  25. return [element for element in globals.get_client().elements.values() if isinstance(element, ui.left_drawer)][0]
  26. def heading(text: str, *, make_menu_entry: bool = True) -> None:
  27. ui.html(f'<em>{text}</em>').classes('mt-8 text-3xl font-weight-500')
  28. if make_menu_entry:
  29. with get_menu():
  30. ui.label(text).classes('font-bold mt-4')
  31. def subheading(text: str, *, make_menu_entry: bool = True) -> None:
  32. name = create_anchor_name(text)
  33. ui.html(f'<div id="{name}"></div>').style('position: relative; top: -90px')
  34. with ui.row().classes('gap-2 items-center'):
  35. ui.label(text).classes('text-2xl')
  36. with ui.link(target=f'#{name}'):
  37. ui.icon('link', size='sm').classes('text-gray-400 hover:text-gray-800')
  38. if make_menu_entry:
  39. with get_menu() as menu:
  40. async def click():
  41. if await ui.run_javascript(f'!!document.querySelector("div.q-drawer__backdrop")'):
  42. menu.hide()
  43. ui.open(f'#{name}')
  44. ui.link(text, target=f'#{name}').props('data-close-overlay').on('click', click)
  45. def markdown(text: str) -> ui.markdown:
  46. return ui.markdown(remove_indentation(text))
  47. def render_docstring(doc: str) -> ui.html:
  48. doc = remove_indentation(doc)
  49. doc = doc.replace('param ', '')
  50. html = docutils.core.publish_parts(doc, writer_name='html5_polyglot')['html_body']
  51. html = apply_tailwind(html)
  52. return ui.html(html).classes('documentation bold-links arrow-links')
  53. class text_demo:
  54. def __init__(self, title: str, explanation: str) -> None:
  55. self.title = title
  56. self.explanation = explanation
  57. self.make_menu_entry = True
  58. def __call__(self, f: Callable) -> Callable:
  59. subheading(self.title, make_menu_entry=self.make_menu_entry)
  60. markdown(self.explanation)
  61. return demo()(f)
  62. class intro_demo(text_demo):
  63. def __init__(self, title: str, explanation: str) -> None:
  64. super().__init__(title, explanation)
  65. self.make_menu_entry = False
  66. class element_demo:
  67. def __init__(self, element_class: Union[Callable, type], browser_title: Optional[str] = None) -> None:
  68. self.element_class = element_class
  69. self.browser_title = browser_title
  70. def __call__(self, f: Callable, *, more_link: Optional[str] = None) -> Callable:
  71. doc = self.element_class.__doc__ or self.element_class.__init__.__doc__
  72. title, documentation = doc.split('\n', 1)
  73. with ui.column().classes('w-full mb-8 gap-2'):
  74. subheading(title)
  75. render_docstring(documentation)
  76. wrapped = demo(browser_title=self.browser_title)(f)
  77. if more_link:
  78. ui.markdown(f'[More...](documentation/{more_link})').classes('bold-links mt-2')
  79. return wrapped
  80. def load_demo(element_class: type) -> None:
  81. name = pascal_to_snake(element_class.__name__)
  82. try:
  83. module = importlib.import_module(f'website.more_documentation.{name}_documentation')
  84. except ModuleNotFoundError:
  85. module = importlib.import_module(f'website.more_documentation.{name.replace("_", "")}_documentation')
  86. element_demo(element_class)(getattr(module, 'main_demo'), more_link=name)
  87. def is_method(cls: type, attribute_name: str) -> bool:
  88. attribute = cls.__dict__.get(attribute_name, None)
  89. return inspect.isfunction(attribute) or inspect.ismethod(attribute)
  90. def generate_class_doc(class_obj: type) -> None:
  91. class_name = pascal_to_snake(class_obj.__name__)
  92. bases = [base for base in class_obj.__mro__[1:-1] if base.__module__.startswith('nicegui.')]
  93. methods_names = []
  94. for base in bases:
  95. for name in dir(base):
  96. if not name.startswith('_') and is_method(base, name):
  97. methods_names.append(name)
  98. if methods_names:
  99. subheading('Methods')
  100. with ui.column().classes('gap-2'):
  101. for method_name in sorted(methods_names):
  102. method = getattr(class_obj, method_name)
  103. ui.markdown(f'`{class_name}.`**`{method_name}`**`{generate_method_signature_description(method)}`')
  104. if method.__doc__:
  105. render_docstring(method.__doc__).classes('ml-8')
  106. if bases:
  107. subheading('Inherited from')
  108. with ui.column().classes('gap-2'):
  109. for base in bases:
  110. ui.markdown(f'- `{base.__name__}`')
  111. def generate_method_signature_description(method: Callable) -> str:
  112. param_strings = []
  113. for param in inspect.signature(method).parameters.values():
  114. param_string = param.name
  115. if param_string == 'self':
  116. continue
  117. if param.annotation != inspect.Parameter.empty:
  118. param_type = inspect.formatannotation(param.annotation)
  119. param_string += f''': {param_type.strip("'")}'''
  120. if param.default != inspect.Parameter.empty:
  121. param_string += f' = [...]' if callable(param.default) else f' = {repr(param.default)}'
  122. if param.kind == inspect.Parameter.VAR_POSITIONAL:
  123. param_string = f'*{param_string}'
  124. param_strings.append(param_string)
  125. method_signature = ', '.join(param_strings)
  126. description = f'({method_signature})'
  127. return_annotation = inspect.signature(method).return_annotation
  128. if return_annotation != inspect.Parameter.empty:
  129. return_type = inspect.formatannotation(return_annotation)
  130. return_description = f''' -> {return_type.strip("'")}'''
  131. description += return_description
  132. return description