fetch_tailwind.py 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. #!/usr/bin/env python3
  2. import re
  3. from dataclasses import dataclass, field
  4. from pathlib import Path
  5. from typing import List
  6. import requests
  7. from bs4 import BeautifulSoup
  8. @dataclass
  9. class Property:
  10. title: str
  11. description: str
  12. members: List[str]
  13. short_members: List[str] = field(init=False)
  14. common_prefix: str = field(init=False)
  15. def __post_init__(self) -> None:
  16. words = [s.split('-') for s in self.members]
  17. prefix = words[0]
  18. for w in words:
  19. i = 0
  20. while i < len(prefix) and i < len(w) and prefix[i] == w[i]:
  21. i += 1
  22. prefix = prefix[:i]
  23. if not prefix:
  24. break
  25. self.short_members = ['-'.join(word[len(prefix):]) for word in words]
  26. self.common_prefix = '-'.join(prefix) + '-' if prefix else ''
  27. if len(self.short_members) == 1:
  28. if self.title == 'Container':
  29. self.members.clear()
  30. self.short_members.clear()
  31. self.common_prefix = 'container'
  32. elif self.title in {'List Style Image', 'Content', 'Appearance'}:
  33. self.short_members = ['none']
  34. self.common_prefix = self.members[0].removesuffix('-none')
  35. else:
  36. raise ValueError(f'Unknown single-value property "{self.title}"')
  37. @property
  38. def pascal_title(self) -> str:
  39. return ''.join(word.capitalize() for word in re.sub(r'[-/ &]', ' ', self.title).split())
  40. @property
  41. def snake_title(self) -> str:
  42. return '_'.join(word.lower() for word in re.sub(r'[-/ &]', ' ', self.title).split())
  43. def get_soup(url: str) -> BeautifulSoup:
  44. """Get the BeautifulSoup object for the given URL."""
  45. path = Path('/tmp/nicegui_tailwind') / url.split('/')[-1]
  46. path.parent.mkdir(parents=True, exist_ok=True)
  47. if path.exists():
  48. html = path.read_text(encoding='utf-8')
  49. else:
  50. req = requests.get(url, timeout=5)
  51. html = req.text
  52. path.write_text(html, encoding='utf-8')
  53. return BeautifulSoup(html, 'html.parser')
  54. def collect_properties() -> List[Property]:
  55. """Collect all Tailwind properties from the documentation."""
  56. properties: List[Property] = []
  57. soup = get_soup('https://tailwindcss.com/docs')
  58. for li in soup.select('li[class="mt-12 lg:mt-8"]'):
  59. title = li.select_one('h5').text
  60. links = li.select('li a')
  61. if title in {'Getting Started', 'Core Concepts', 'Customization', 'Base Styles', 'Official Plugins'}:
  62. continue
  63. print(f'{title}:')
  64. for a in links:
  65. soup = get_soup(f'https://tailwindcss.com{a["href"]}')
  66. title = soup.select_one('#header h1').text
  67. description = soup.select_one('#header .mt-2').text
  68. members = soup.select('.mt-10 td[class*=text-sky-400]')
  69. properties.append(Property(title, description, [p.text.split(' ')[0] for p in members]))
  70. print(f'\t{title} ({len(members)})')
  71. return properties
  72. def generate_type_files(properties: List[Property]) -> None:
  73. """Generate the type files for the Tailwind properties."""
  74. for file in (Path(__file__).parent / 'nicegui' / 'tailwind_types').glob('*.py'):
  75. file.unlink()
  76. (Path(__file__).parent / 'nicegui' / 'tailwind_types' / '__init__.py').touch()
  77. for property_ in properties:
  78. if not property_.members:
  79. continue
  80. with (Path(__file__).parent / 'nicegui' / 'tailwind_types' / f'{property_.snake_title}.py') \
  81. .open('w', encoding='utf-8') as f:
  82. f.write('from typing import Literal\n')
  83. f.write('\n')
  84. f.write(f'{property_.pascal_title} = Literal[\n')
  85. for short_member in property_.short_members:
  86. f.write(f" '{short_member}',\n")
  87. f.write(']\n')
  88. def generate_tailwind_file(properties: List[Property]) -> None:
  89. """Generate the tailwind.py file."""
  90. with (Path(__file__).parent / 'nicegui' / 'tailwind.py').open('w', encoding='utf-8') as f:
  91. f.write('# pylint: disable=too-many-lines\n')
  92. f.write('from __future__ import annotations\n')
  93. f.write('\n')
  94. f.write('from typing import TYPE_CHECKING, List, Optional, Union, overload\n')
  95. f.write('\n')
  96. f.write('if TYPE_CHECKING:\n')
  97. f.write(' from .element import Element\n')
  98. for property_ in sorted(properties, key=lambda p: p.title):
  99. if not property_.members:
  100. continue
  101. f.write(f' from .tailwind_types.{property_.snake_title} import {property_.pascal_title}\n')
  102. f.write('\n')
  103. f.write('\n')
  104. f.write('class PseudoElement:\n')
  105. f.write('\n')
  106. f.write(' def __init__(self) -> None:\n')
  107. f.write(' self._classes: List[str] = []\n')
  108. f.write('\n')
  109. f.write(' def classes(self, add: str) -> None:\n')
  110. f.write(' """Add the given classes to the element."""\n')
  111. f.write(' self._classes.append(add)\n')
  112. f.write('\n')
  113. f.write('\n')
  114. f.write('class Tailwind:\n')
  115. f.write('\n')
  116. f.write(' def __init__(self, _element: Optional[Element] = None) -> None:\n')
  117. f.write(
  118. ' self.element: Union[PseudoElement, Element] = PseudoElement() if _element is None else _element\n')
  119. f.write('\n')
  120. f.write(' @overload\n')
  121. f.write(' def __call__(self, tailwind: Tailwind) -> Tailwind:\n')
  122. f.write(' ...\n')
  123. f.write('\n')
  124. f.write(' @overload\n')
  125. f.write(' def __call__(self, *classes: str) -> Tailwind:\n')
  126. f.write(' ...\n')
  127. f.write('\n')
  128. f.write(' def __call__(self, *args) -> Tailwind: # type: ignore\n')
  129. f.write(' if not args:\n')
  130. f.write(' return self\n')
  131. f.write(' if isinstance(args[0], Tailwind):\n')
  132. f.write(' args[0].apply(self.element) # type: ignore\n')
  133. f.write(' else:\n')
  134. f.write(" self.element.classes(' '.join(args))\n")
  135. f.write(' return self\n')
  136. f.write('\n')
  137. f.write(' def apply(self, element: Element) -> None:\n')
  138. f.write(' """Apply the tailwind classes to the given element."""\n')
  139. f.write(' element._classes.extend(self.element._classes) # pylint: disable=protected-access\n')
  140. f.write(' element.update()\n')
  141. for property_ in properties:
  142. f.write('\n')
  143. prefix = property_.common_prefix
  144. if property_.members:
  145. f.write(f' def {property_.snake_title}(self, value: {property_.pascal_title}) -> Tailwind:\n')
  146. f.write(f' """{property_.description}"""\n')
  147. if '' in property_.short_members:
  148. f.write(
  149. f" self.element.classes('{prefix}' + value if value else '{prefix.rstrip('''-''')}')\n")
  150. else:
  151. f.write(f" self.element.classes('{prefix}' + value)\n")
  152. f.write(' return self\n')
  153. else:
  154. f.write(f' def {property_.snake_title}(self) -> Tailwind:\n')
  155. f.write(f' """{property_.description}"""\n')
  156. f.write(f" self.element.classes('{prefix}')\n")
  157. f.write(' return self\n')
  158. def main() -> None:
  159. """Collect all Tailwind properties from the documentation and generate the Python files."""
  160. properties = collect_properties()
  161. generate_type_files(properties)
  162. generate_tailwind_file(properties)
  163. if __name__ == '__main__':
  164. main()