element_filter.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. from __future__ import annotations
  2. from typing import Generic, Iterator, List, Optional, Type, TypeVar, Union, overload
  3. from typing_extensions import Self
  4. from .context import context
  5. from .element import Element
  6. from .elements.mixins.content_element import ContentElement
  7. from .elements.mixins.source_element import SourceElement
  8. from .elements.mixins.text_element import TextElement
  9. from .elements.notification import Notification
  10. from .elements.radio import Radio
  11. from .elements.select import Select
  12. from .elements.toggle import Toggle
  13. T = TypeVar('T', bound=Element)
  14. class ElementFilter(Generic[T]):
  15. DEFAULT_LOCAL_SCOPE = False
  16. @overload
  17. def __init__(self: ElementFilter[Element], *,
  18. marker: Union[str, List[str], None] = None,
  19. content: Union[str, List[str], None] = None,
  20. local_scope: bool = DEFAULT_LOCAL_SCOPE,
  21. ) -> None:
  22. ...
  23. @overload
  24. def __init__(self, *,
  25. kind: Type[T],
  26. marker: Union[str, List[str], None] = None,
  27. content: Union[str, List[str], None] = None,
  28. local_scope: bool = DEFAULT_LOCAL_SCOPE,
  29. ) -> None:
  30. ...
  31. def __init__(self, *,
  32. kind: Optional[Type[T]] = None,
  33. marker: Union[str, List[str], None] = None,
  34. content: Union[str, List[str], None] = None,
  35. local_scope: bool = DEFAULT_LOCAL_SCOPE,
  36. ) -> None:
  37. """ElementFilter
  38. Sometimes it is handy to search the Python element tree of the current page.
  39. ``ElementFilter()`` allows powerful filtering by kind of elements, markers and content.
  40. It also provides a fluent interface to apply more filters like excluding elements or filtering for elements within a specific parent.
  41. The filter can be used as an iterator to iterate over the found elements and is always applied while iterating and not when being instantiated.
  42. And element is yielded if it matches all of the following conditions:
  43. - The element is of the specified kind (if specified).
  44. - The element is none of the excluded kinds.
  45. - The element has all of the specified markers.
  46. - The element has none of the excluded markers.
  47. - The element contains all of the specified content.
  48. - The element contains none of the excluded content.
  49. - Its ancestors include all of the specified instances defined via ``within``.
  50. - Its ancestors include none of the specified instances defined via ``not_within``.
  51. - Its ancestors include all of the specified kinds defined via ``within``.
  52. - Its ancestors include none of the specified kinds defined via ``not_within``.
  53. - Its ancestors include all of the specified markers defined via ``within``.
  54. - Its ancestors include none of the specified markers defined via ``not_within``.
  55. Element "content" includes its text, label, icon, placeholder, value, message, content, source.
  56. Partial matches like "Hello" in "Hello World!" are sufficient for content filtering.
  57. :param kind: filter by element type; the iterator will be of type ``kind``
  58. :param marker: filter by element markers; can be a list of strings or a single string where markers are separated by whitespace
  59. :param content: filter for elements which contain ``content`` in one of their content attributes like ``.text``, ``.value``, ``.source``, ...; can be a singe string or a list of strings which all must match
  60. :param local_scope: if `True`, only elements within the current scope are returned; by default the whole page is searched (this default behavior can be changed with ``ElementFilter.DEFAULT_LOCAL_SCOPE = True``)
  61. """
  62. self._kind = kind
  63. self._markers = marker.split() if isinstance(marker, str) else marker or []
  64. self._contents = [content] if isinstance(content, str) else content or []
  65. self._within_kinds: List[Type[Element]] = []
  66. self._within_instances: List[Element] = []
  67. self._within_markers: List[str] = []
  68. self._not_within_kinds: List[Type[Element]] = []
  69. self._not_within_instances: List[Element] = []
  70. self._not_within_markers: List[str] = []
  71. self._exclude_kinds: List[Type[Element]] = []
  72. self._exclude_markers: List[str] = []
  73. self._exclude_content: List[str] = []
  74. self._scope = context.slot.parent if local_scope else context.client.layout
  75. def __iter__(self) -> Iterator[T]:
  76. for element in self._scope.descendants():
  77. if self._kind and not isinstance(element, self._kind):
  78. continue
  79. if self._exclude_kinds and isinstance(element, tuple(self._exclude_kinds)):
  80. continue
  81. if any(marker not in element._markers for marker in self._markers):
  82. continue
  83. if any(marker in element._markers for marker in self._exclude_markers):
  84. continue
  85. if self._contents or self._exclude_content:
  86. element_contents = [content for content in (
  87. element.props.get('text'),
  88. element.props.get('label'),
  89. element.props.get('icon'),
  90. element.props.get('placeholder'),
  91. element.props.get('value'),
  92. element.props.get('error-message'),
  93. element.text if isinstance(element, TextElement) else None,
  94. element.content if isinstance(element, ContentElement) else None,
  95. element.source if isinstance(element, SourceElement) else None,
  96. ) if content]
  97. if isinstance(element, Notification):
  98. element_contents.append(element.message)
  99. if isinstance(element, (Select, Radio, Toggle)):
  100. options = {option['value']: option['label'] for option in element.props.get('options', [])}
  101. element_contents.append(options.get(element.value, ''))
  102. if not isinstance(element, Select) or element.is_showing_popup:
  103. element_contents.extend(options.values())
  104. if any(all(needle not in str(haystack) for haystack in element_contents) for needle in self._contents):
  105. continue
  106. if any(needle in str(haystack) for haystack in element_contents for needle in self._exclude_content):
  107. continue
  108. ancestors = set(element.ancestors())
  109. if self._within_instances and not ancestors.issuperset(self._within_instances):
  110. continue
  111. if self._not_within_instances and not ancestors.isdisjoint(self._not_within_instances):
  112. continue
  113. if self._within_kinds and not all(any(isinstance(ancestor, kind) for ancestor in ancestors) for kind in self._within_kinds):
  114. continue
  115. if self._not_within_kinds and any(isinstance(ancestor, tuple(self._not_within_kinds)) for ancestor in ancestors):
  116. continue
  117. ancestor_markers = {marker for ancestor in ancestors for marker in ancestor._markers}
  118. if self._within_markers and not ancestor_markers.issuperset(self._within_markers):
  119. continue
  120. if self._not_within_markers and not ancestor_markers.isdisjoint(self._not_within_markers):
  121. continue
  122. yield element # type: ignore
  123. def within(self, *,
  124. kind: Optional[Type[Element]] = None,
  125. marker: Optional[str] = None,
  126. instance: Union[Element, List[Element], None] = None,
  127. ) -> Self:
  128. """Filter elements which have a specific match in the parent hierarchy."""
  129. if kind is not None:
  130. assert issubclass(kind, Element)
  131. self._within_kinds.append(kind)
  132. if marker is not None:
  133. self._within_markers.extend(marker.split())
  134. if instance is not None:
  135. self._within_instances.extend(instance if isinstance(instance, list) else [instance])
  136. return self
  137. def exclude(self, *,
  138. kind: Optional[Type[Element]] = None,
  139. marker: Optional[str] = None,
  140. content: Optional[str] = None,
  141. ) -> Self:
  142. """Exclude elements with specific element type, marker or content."""
  143. if kind is not None:
  144. assert issubclass(kind, Element)
  145. self._exclude_kinds.append(kind)
  146. if marker is not None:
  147. self._exclude_markers.append(marker)
  148. if content is not None:
  149. self._exclude_content.append(content)
  150. return self
  151. def not_within(self, *,
  152. kind: Optional[Type[Element]] = None,
  153. marker: Optional[str] = None,
  154. instance: Union[Element, List[Element], None] = None,
  155. ) -> Self:
  156. """Exclude elements which have a parent of a specific type or marker."""
  157. if kind is not None:
  158. assert issubclass(kind, Element)
  159. self._not_within_kinds.append(kind)
  160. if marker is not None:
  161. self._not_within_markers.extend(marker.split())
  162. if instance is not None:
  163. self._not_within_instances.extend(instance if isinstance(instance, list) else [instance])
  164. return self
  165. def classes(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) -> Self:
  166. """Apply, remove, or replace HTML classes.
  167. This allows modifying the look of the element or its layout using `Tailwind <https://tailwindcss.com/>`_ or `Quasar <https://quasar.dev/>`_ classes.
  168. Removing or replacing classes can be helpful if predefined classes are not desired.
  169. :param add: whitespace-delimited string of classes
  170. :param remove: whitespace-delimited string of classes to remove from the element
  171. :param replace: whitespace-delimited string of classes to use instead of existing ones
  172. """
  173. for element in self:
  174. element.classes(add, remove=remove, replace=replace)
  175. return self
  176. def style(self, add: Optional[str] = None, *, remove: Optional[str] = None, replace: Optional[str] = None) -> Self:
  177. """Apply, remove, or replace CSS definitions.
  178. Removing or replacing styles can be helpful if the predefined style is not desired.
  179. :param add: semicolon-separated list of styles to add to the element
  180. :param remove: semicolon-separated list of styles to remove from the element
  181. :param replace: semicolon-separated list of styles to use instead of existing ones
  182. """
  183. for element in self:
  184. element.style(add, remove=remove, replace=replace)
  185. return self
  186. def props(self, add: Optional[str] = None, *, remove: Optional[str] = None) -> Self:
  187. """Add or remove props.
  188. This allows modifying the look of the element or its layout using `Quasar <https://quasar.dev/>`_ props.
  189. Since props are simply applied as HTML attributes, they can be used with any HTML element.
  190. Boolean properties are assumed ``True`` if no value is specified.
  191. :param add: whitespace-delimited list of either boolean values or key=value pair to add
  192. :param remove: whitespace-delimited list of property keys to remove
  193. """
  194. for element in self:
  195. element.props(add, remove=remove)
  196. return self