Coverage for src/metador_core/widget/common.py: 80%

136 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-11-02 09:33 +0000

1"""Common generic widgets. 

2 

3These basically integrate many default widgets provided by panel/bokeh into Metador. 

4""" 

5 

6import json 

7from io import StringIO 

8from typing import List, Set, Type 

9 

10import pandas as pd 

11import panel as pn 

12from overrides import overrides 

13from panel.viewable import Viewable 

14 

15from metador_core.schema import MetadataSchema 

16 

17from ..plugins import schemas 

18from . import Widget 

19 

20FileMeta = schemas.get("core.file", (0, 1, 0)) 

21ImageFileMeta = schemas.get("core.imagefile", (0, 1, 0)) 

22 

23 

24class FileWidget(Widget): 

25 """Simple widget based on (a subschema of) 'core.file'. 

26 

27 Allows to state supported MIME types with less boilerplate. 

28 """ 

29 

30 class Plugin: 

31 # name and version must be overridden in subclasses 

32 supports = [FileMeta.Plugin.ref()] 

33 

34 MIME_TYPES: Set[str] = set() 

35 """If non-empty, metadata objects must have a MIME type from this set.""" 

36 

37 FILE_EXTS: Set[str] = set() 

38 """If non-empty, filename must have an extension from this set.""" 

39 

40 @property 

41 def title(self) -> str: 

42 return self._meta.name or self._meta.filename or self._node.name 

43 

44 @classmethod 

45 @overrides 

46 def supports_meta(cls, obj: MetadataSchema) -> bool: 

47 supported_mime = True 

48 if cls.MIME_TYPES: 

49 supported_mime = obj.encodingFormat in cls.MIME_TYPES 

50 supported_ext = False 

51 if cls.FILE_EXTS: 

52 supported_ext = obj.filename.endswith(tuple(cls.FILE_EXTS)) 

53 # either supported mime or supported ext is enough 

54 return supported_mime or supported_ext 

55 

56 

57# wrap simple generic widgets from panel: 

58 

59# pass content: 

60 

61 

62class CSVWidget(FileWidget): 

63 class Plugin(FileWidget.Plugin): 

64 name = "core.file.csv" 

65 version = (0, 1, 0) 

66 

67 MIME_TYPES = {"text/csv", "text/tab-separated-values"} 

68 FILE_EXTS = {".csv", ".tsv"} 

69 

70 @overrides 

71 def show(self) -> Viewable: 

72 df = pd.read_csv( 

73 StringIO(self.file_data().decode("utf-8")), 

74 sep=None, # auto-infer csv/tsv 

75 engine="python", # mute warning 

76 # smart date processing 

77 parse_dates=True, 

78 dayfirst=True, 

79 cache_dates=True, 

80 ) 

81 return pn.widgets.DataFrame( 

82 df, 

83 disabled=True, 

84 ) 

85 # return pn.widgets.Tabulator( 

86 # df, 

87 # disabled=True, 

88 # layout="fit_data_table", 

89 # # NOTE: pagination looks buggy, table sometimes won't show up 

90 # # need to investigate that further, maybe it's a bug 

91 # # pagination="remote", 

92 # # page_size=10, 

93 # ) 

94 

95 

96class MarkdownWidget(FileWidget): 

97 class Plugin(FileWidget.Plugin): 

98 name = "core.file.text.md" 

99 version = (0, 1, 0) 

100 

101 MIME_TYPES = {"text/markdown", "text/x-markdown"} 

102 FILE_EXTS = {".md", ".markdown"} 

103 

104 @overrides 

105 def show(self) -> Viewable: 

106 return pn.pane.Markdown( 

107 self.file_data().decode("utf-8"), max_width=self._w, max_height=self._h 

108 ) 

109 

110 

111class HTMLWidget(FileWidget): 

112 class Plugin(FileWidget.Plugin): 

113 name = "core.file.text.html" 

114 version = (0, 1, 0) 

115 

116 MIME_TYPES = {"text/html"} 

117 FILE_EXTS = {".htm", ".html"} 

118 

119 @overrides 

120 def show(self) -> Viewable: 

121 return pn.pane.HTML( 

122 self.file_data().decode("utf-8"), max_width=self._w, max_height=self._h 

123 ) 

124 

125 

126class CodeWidget(FileWidget): 

127 class Plugin(FileWidget.Plugin): 

128 name = "core.file.text.code" 

129 version = (0, 1, 0) 

130 

131 @classmethod 

132 @overrides 

133 def supports_meta(cls, obj: MetadataSchema) -> bool: 

134 return obj.encodingFormat.startswith("text") 

135 

136 def show(self) -> Viewable: 

137 # return pn.widgets.CodeEditor( # in Panel > 1.0 

138 return pn.widgets.Ace( 

139 filename=self._meta.filename, 

140 readonly=True, 

141 value=self.file_data().decode("utf-8"), 

142 width=self._w, 

143 height=self._h, 

144 ) 

145 

146 

147class DispatcherWidget(Widget): 

148 """Meta-widget to dispatch a node+metadata object to a more specific widget. 

149 

150 Make sure that the dispatcher widget is probed before the widgets it can 

151 dispatch to. 

152 

153 This works if and only if for each widget in listed in `WIDGETS`: 

154 * the plugin name of the dispatcher is a prefix of the widget name, or 

155 * the widget has `primary = False` and thus is not considered by the dashboard. 

156 """ 

157 

158 WIDGETS: List[Type[Widget]] 

159 """Widgets in the order they should be tested.""" 

160 

161 def dispatch(self, w_cls: Type[Widget]) -> Widget: 

162 """Dispatch to another widget (used by meta-widgets).""" 

163 return w_cls( 

164 self._node, 

165 "", 

166 server=self._server, 

167 metadata=self._meta, 

168 max_width=self._w, 

169 max_height=self._h, 

170 ) 

171 

172 @classmethod 

173 @overrides 

174 def supports_meta(cls, obj: MetadataSchema) -> bool: 

175 return any(map(lambda w: w.supports_meta(obj), cls.WIDGETS)) 

176 

177 @overrides 

178 def setup(self): 

179 for w_cls in self.WIDGETS: 

180 if w_cls.supports_meta(self._meta): 

181 self._widget = self.dispatch(w_cls) 

182 break 

183 

184 @overrides 

185 def show(self) -> Viewable: 

186 return self._widget.show() 

187 

188 

189class TextWidget(DispatcherWidget, FileWidget): 

190 class Plugin(FileWidget.Plugin): 

191 name = "core.file.text" 

192 version = (0, 1, 0) 

193 

194 WIDGETS = [MarkdownWidget, HTMLWidget, CodeWidget] 

195 

196 def show(self) -> Viewable: 

197 return super().show() 

198 

199 

200class JSONWidget(FileWidget): 

201 class Plugin(FileWidget.Plugin): 

202 name = "core.file.json" 

203 version = (0, 1, 0) 

204 

205 MIME_TYPES = {"application/json"} 

206 

207 @overrides 

208 def show(self) -> Viewable: 

209 return pn.pane.JSON( 

210 json.loads(self.file_data()), 

211 name=self.title, 

212 max_width=self._w, 

213 max_height=self._h, 

214 hover_preview=True, 

215 depth=-1, 

216 ) 

217 

218 

219# pass URL: 

220 

221 

222class PDFWidget(FileWidget): 

223 class Plugin(FileWidget.Plugin): 

224 name = "core.file.pdf" 

225 version = (0, 1, 0) 

226 

227 MIME_TYPES = {"application/pdf"} 

228 

229 @overrides 

230 def show(self) -> Viewable: 

231 return pn.pane.PDF(self.file_url(), width=self._w, height=self._h) 

232 

233 

234class ImageWidget(FileWidget): 

235 class Plugin(FileWidget.Plugin): 

236 name = "core.file.image" 

237 version = (0, 1, 0) 

238 

239 MIME_TYPES = {"image/jpeg", "image/png", "image/gif", "image/svg+xml"} 

240 PANEL_WIDGET = { 

241 "image/jpeg": pn.pane.JPG, 

242 "image/png": pn.pane.PNG, 

243 "image/gif": pn.pane.GIF, 

244 "image/svg+xml": pn.pane.SVG, 

245 } 

246 

247 @overrides 

248 def show(self) -> Viewable: 

249 return self.PANEL_WIDGET[self._meta.encodingFormat]( 

250 self.file_url(), 

251 width=self._w, 

252 height=self._h, 

253 alt_text="Sorry. Something went wrong while loading the resource", 

254 ) 

255 

256 

257# NOTE: Panel based widget does not work. 

258# see - https://github.com/holoviz/panel/issues/3203 

259# using HTML based audio & video panes for the audio and video widgets respectively 

260class AudioWidget(FileWidget): 

261 class Plugin(FileWidget.Plugin): 

262 name = "core.file.audio" 

263 version = (0, 1, 0) 

264 

265 MIME_TYPES = {"audio/mpeg", "audio/ogg", "audio/webm"} 

266 

267 @overrides 

268 def show(self) -> Viewable: 

269 return pn.pane.HTML( 

270 f""" 

271 <audio controls> 

272 <source src={self.file_url()} type={self._meta.encodingFormat}> 

273 </audio> 

274 """, 

275 ) 

276 

277 

278class VideoWidget(FileWidget): 

279 class Plugin(FileWidget.Plugin): 

280 name = "core.file.video" 

281 version = (0, 1, 0) 

282 

283 MIME_TYPES = {"video/mp4", "video/ogg", "video/webm"} 

284 

285 @overrides 

286 def show(self) -> Viewable: 

287 return pn.pane.HTML( 

288 f""" 

289 <video width={self._w} height={self._h} controls style="object-position: center; object-fit:cover;"> 

290 <source src={self.file_url()} type={self._meta.encodingFormat}> 

291 </video> 

292 """, 

293 )