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

100 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2025-04-06 18:07 +0200

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 """ 

40 Object to interact with slots from the inside of a component. 

41 """ 

42 

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

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

45 self._default = default or "" 

46 if not slots: 

47 self._slots[self._default] = [] 

48 

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

50 slot = slot or self._default 

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

52 

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

54 """ 

55 Insert the contents of a given slot. 

56 

57 Args: 

58 slot: The slot name, or `None` to refer to the default slot. 

59 

60 When used as a context manager, nodes created within the context will 

61 be inserted if the slot has not been filled (default content). 

62 """ 

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

64 register_with_context(obj) 

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

66 

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

68 """ 

69 Returns whether or not the slot has been filled. 

70 

71 Args: 

72 slot: The slot name, or `None` to refer to the default slot. 

73 """ 

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

75 

76 

77P = ParamSpec("P") 

78 

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

80 

81 

82class Component: 

83 """ 

84 An instance of a component. 

85 

86 To fill the default slot, use the component as a context manager. 

87 

88 To fill a slot by name, use the :meth:`slot` method. 

89 """ 

90 

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

92 self._callback = callback 

93 self._slots = slots 

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

95 

96 def __enter__(self) -> Self: 

97 self._capture = ElementNonEmpty("__capture__") 

98 push_element_context(self._capture) 

99 return self 

100 

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

102 parent, content = pop_element_context() 

103 assert parent is self._capture 

104 if content: 

105 self._slots.add_content(None, content) 

106 

107 @contextmanager 

108 def slot(self, slot: str | None = None) -> Iterator[None]: 

109 """ 

110 Args: 

111 slot: The slot name, or `None` to refer to the default slot. 

112 

113 Context manager to add content to a slot. Nodes created within the 

114 context will be added to the slot. 

115 """ 

116 capture = ElementNonEmpty("__capture__") 

117 push_element_context(capture) 

118 try: 

119 yield 

120 finally: 

121 parent, content = pop_element_context() 

122 assert parent is capture 

123 self._slots.add_content(slot, content) 

124 

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

126 if self._cached_nodes is None: 

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

128 # active context. 

129 capture = ElementNonEmpty("__capture__") 

130 push_element_context(capture) 

131 result = self._callback(self._slots) 

132 parent, _ = pop_element_context() 

133 assert parent is capture 

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

135 return self._cached_nodes 

136 

137 def __str__(self) -> str: 

138 buf = io.StringIO() 

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

140 return buf.getvalue() 

141 

142 

143class ComponentWrapper(Generic[P]): 

144 """ 

145 Wraps a component implementation. 

146 

147 Decorating a function with the :deco:`component` decorator replaces the 

148 function with a `ComponentWrapper`. 

149 """ 

150 

151 def __init__( 

152 self, 

153 impl: ComponentImpl[P], 

154 slots: Sequence[str], 

155 default: str | None, 

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

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

158 ): 

159 self._impl = impl 

160 self._slots = slots 

161 self._default = default 

162 self._styles = styles 

163 self._scripts = scripts 

164 

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

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

167 slots, *args, **kwargs 

168 ) 

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

170 register_with_context(component) 

171 if self._styles: 

172 register_template_styles(self._styles) 

173 if self._scripts: 

174 register_template_scripts(self._scripts) 

175 return component 

176 

177 

178def component( 

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

180 default: str | None = None, 

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

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

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

184 """ 

185 Decorator to create a component. 

186 

187 Args: 

188 slots: A list of slot names. When omitted, the component will have one 

189 default unnamed slot. 

190 default: One of the names in `slots` that should be the default slot. 

191 When `slots` is set but not `default`, the component's slots always 

192 have to be referred to by name. 

193 style: Associate one or more style nodes with the component. 

194 script: Associate one or more script nodes with the component. 

195 

196 When called, the decorated function receives a :class:`Slots` object as its 

197 first argument. 

198 """ 

199 slots = slots or [] 

200 if default and not slots: 

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

202 elif default and default not in slots: 

203 raise ValueError( 

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

205 ) 

206 

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

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

209 

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

211 return ComponentWrapper( 

212 fn, slots=slots, default=default, styles=styles, scripts=scripts 

213 ) 

214 

215 return decorator