Coverage for src/minihtml/_component.py: 98%

100 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-02-22 21:44 +0100

1import io 

2import sys 

3from collections.abc import Iterable, Iterator, Sequence 

4from contextlib import contextmanager 

5from typing import Callable, Concatenate, Generic, ParamSpec, TypeAlias 

6 

7if sys.version_info >= (3, 11): 7 ↛ 10line 7 didn't jump to line 10 because the condition on line 7 was always true

8 from typing import Self 

9else: 

10 from typing_extensions import Self 

11 

12from ._core import ( 

13 ElementNonEmpty, 

14 HasNodes, 

15 Node, 

16 iter_nodes, 

17 pop_element_context, 

18 push_element_context, 

19 register_with_context, 

20) 

21from ._template_context import register_template_scripts, register_template_styles 

22 

23 

24class SlotContext: 

25 def __init__(self, capture: bool): 

26 self._capture = capture 

27 

28 def __enter__(self) -> None: 

29 if self._capture: 

30 capture = ElementNonEmpty("__capture__") 

31 push_element_context(capture) 

32 

33 def __exit__(self, *exc_info: object) -> None: 

34 if self._capture: 

35 pop_element_context() 

36 

37 

38class Slots: 

39 def __init__(self, slots: Sequence[str], default: str | None): 

40 self._slots: dict[str, list[Node | HasNodes]] = {slot: [] for slot in slots} 

41 self._default = default or "" 

42 if not slots: 

43 self._slots[""] = [] 

44 

45 def add_content(self, slot: str | None, content: list[Node | HasNodes]) -> None: 

46 slot = slot or self._default 

47 self._slots[slot].extend(content) 

48 

49 def slot(self, slot: str | None = None) -> SlotContext: 

50 for obj in self._slots[slot or self._default]: 

51 register_with_context(obj) 

52 return SlotContext(capture=self.is_filled(slot)) 

53 

54 def is_filled(self, slot: str | None = None) -> bool: 

55 return bool(self._slots[slot or self._default]) 

56 

57 

58P = ParamSpec("P") 

59 

60ComponentImpl: TypeAlias = Callable[Concatenate[Slots, P], Node | HasNodes] 

61 

62 

63class Component: 

64 def __init__(self, callback: Callable[[Slots], Node | HasNodes], slots: Slots): 

65 self._callback = callback 

66 self._slots = slots 

67 self._cached_nodes: list[Node] | None = None 

68 

69 def __enter__(self) -> Self: 

70 self._capture = ElementNonEmpty("__capture__") 

71 push_element_context(self._capture) 

72 return self 

73 

74 def __exit__(self, *exc_info: object) -> None: 

75 parent, children = pop_element_context() 

76 assert parent is self._capture 

77 if children: 

78 self._slots.add_content("", children) 

79 

80 @contextmanager 

81 def slot(self, slot: str = "") -> Iterator[None]: 

82 capture = ElementNonEmpty("__capture__") 

83 push_element_context(capture) 

84 try: 

85 yield 

86 finally: 

87 parent, children = pop_element_context() 

88 assert parent is capture 

89 self._slots.add_content(slot, children) 

90 

91 def get_nodes(self) -> Iterable[Node]: 

92 if self._cached_nodes is None: 

93 # Ensure elements created by self._callback are not registered with the currently 

94 # active context. 

95 capture = ElementNonEmpty("__capture__") 

96 push_element_context(capture) 

97 result = self._callback(self._slots) 

98 parent, _ = pop_element_context() 

99 assert parent is capture 

100 self._cached_nodes = list(iter_nodes([result])) 

101 return self._cached_nodes 

102 

103 def __str__(self) -> str: 

104 buf = io.StringIO() 

105 Node.render_list(buf, self.get_nodes()) 

106 return buf.getvalue() 

107 

108 

109class ComponentWrapper(Generic[P]): 

110 def __init__( 

111 self, 

112 impl: ComponentImpl[P], 

113 slots: Sequence[str], 

114 default: str | None, 

115 styles: Sequence[Node] | None = None, 

116 scripts: Sequence[Node] | None = None, 

117 ): 

118 self._impl = impl 

119 self._slots = slots 

120 self._default = default 

121 self._styles = styles 

122 self._scripts = scripts 

123 

124 def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Component: 

125 callback: Callable[[Slots], Node | HasNodes] = lambda slots: self._impl( 

126 slots, *args, **kwargs 

127 ) 

128 component = Component(callback, slots=Slots(self._slots, default=self._default)) 

129 register_with_context(component) 

130 if self._styles: 

131 register_template_styles(self._styles) 

132 if self._scripts: 

133 register_template_scripts(self._scripts) 

134 return component 

135 

136 

137def component( 

138 slots: Sequence[str] | None = None, 

139 default: str | None = None, 

140 style: Node | Sequence[Node] | None = None, 

141 script: Node | Sequence[Node] | None = None, 

142) -> Callable[[ComponentImpl[P]], ComponentWrapper[P]]: 

143 slots = slots or [] 

144 if default and not slots: 

145 raise ValueError(f"Can't set default without slots: {default!r}") 

146 elif default and default not in slots: 

147 raise ValueError( 

148 f"Invalid default: {default!r}. Available slots: {', '.join(repr(s) for s in slots)}" 

149 ) 

150 

151 styles = [style] if isinstance(style, Node) else style 

152 scripts = [script] if isinstance(script, Node) else script 

153 

154 def decorator(fn: ComponentImpl[P]) -> ComponentWrapper[P]: 

155 return ComponentWrapper( 

156 fn, slots=slots, default=default, styles=styles, scripts=scripts 

157 ) 

158 

159 return decorator