Coverage for src/ipyvizzustory/storylib/story.py: 100%

182 statements  

« prev     ^ index     » next       coverage.py v7.5.4, created at 2024-07-10 10:22 +0000

1"""A module for working with presentation stories.""" 

2 

3from typing import Any, List, Optional, Tuple, Union 

4from os import PathLike 

5import json 

6import re 

7import uuid 

8 

9from ipyvizzu import RawJavaScriptEncoder, Data, Style, Config # , PlainAnimation 

10 

11from ipyvizzustory.storylib.animation import DataFilter 

12from ipyvizzustory.storylib.template import ( 

13 VIZZU_STORY, 

14 DISPLAY_TEMPLATE, 

15 DISPLAY_INDENT, 

16) 

17from ipyvizzustory.__version__ import __version__ 

18 

19 

20class Step(dict): 

21 """A class for representing a step of a slide.""" 

22 

23 def __init__( 

24 self, 

25 *animations: Union[Data, Style, Config], 

26 **anim_options: Optional[Union[str, int, float, dict]], 

27 ): 

28 """ 

29 Step constructor. 

30 

31 Args: 

32 *animations: List of [Data][ipyvizzu.Data], 

33 [Config][ipyvizzu.Config] and [Style][ipyvizzu.Style] objects. 

34 A `Step` can contain each of the above once. 

35 **anim_options: Animation options such as duration. 

36 

37 Raises: 

38 ValueError: If `animations` are not set. 

39 

40 Example: 

41 Initialize a step with a [Config][ipyvizzu.Config] object: 

42 

43 step = Step( 

44 Config({"x": "Foo", "y": "Bar"}) 

45 ) 

46 """ 

47 

48 super().__init__() 

49 if not animations: 

50 raise ValueError("No animation was set.") 

51 self._update(*animations) 

52 

53 if anim_options: 

54 self["animOptions"] = anim_options 

55 

56 def _update(self, *animations: Union[Data, Style, Config]) -> None: 

57 for animation in animations: 

58 if not animation or type(animation) not in [ 

59 Data, 

60 Style, 

61 Config, 

62 ]: # pylint: disable=unidiomatic-typecheck 

63 raise TypeError("Type must be Data, Style or Config.") 

64 if type(animation) == Data: # pylint: disable=unidiomatic-typecheck 

65 animation = DataFilter(animation) 

66 

67 builded_animation = animation.build() 

68 common_keys = set(builded_animation).intersection(set(self)) 

69 if common_keys: 

70 raise ValueError(f"Animation is already merged: {common_keys}") 

71 self.update(builded_animation) 

72 

73 

74class Slide(list): 

75 """A class for representing a slide of a presentation story.""" 

76 

77 def __init__(self, step: Optional[Step] = None): 

78 """ 

79 Slide constructor. 

80 

81 Args: 

82 step: The first step can also be added to the slide in the constructor. 

83 

84 Example: 

85 Initialize a slide without step: 

86 

87 slide = Slide() 

88 

89 Initialize a slide with a step: 

90 

91 slide = Slide( 

92 Step( 

93 Config({"x": "Foo", "y": "Bar"}) 

94 ) 

95 ) 

96 """ 

97 

98 super().__init__() 

99 if step: 

100 self.add_step(step) 

101 

102 def add_step(self, step: Step) -> None: 

103 """ 

104 A method for adding a step for the slide. 

105 

106 Args: 

107 step: The next step of the slide. 

108 

109 Raises: 

110 TypeError: If the type of the `step` is not 

111 [Step][ipyvizzustory.storylib.story.Step]. 

112 

113 Example: 

114 Add steps to a slide: 

115 

116 slide = Slide() 

117 slide.add_step( 

118 Step( 

119 Config({"x": "Foo", "y": "Bar"}) 

120 ) 

121 ) 

122 slide.add_step( 

123 Step( 

124 Config({"color": "Foo", "x": "Baz", "geometry": "circle"}) 

125 ) 

126 ) 

127 """ 

128 

129 if not step or type(step) != Step: # pylint: disable=unidiomatic-typecheck 

130 raise TypeError("Type must be Step.") 

131 self.append(step) 

132 

133 

134class StorySize: 

135 """A class for representing the size of a presentation story.""" 

136 

137 ERROR_MSG_WIDTH_AND_HEIGHT = "width and height should be in pixels" 

138 ERROR_MSG_WIDTH_OR_HEIGHT = "width or height should be in pixels" 

139 ERROR_MSG_ASPECT_RATIO = "aspect_ratio should be a float" 

140 

141 def __init__( 

142 self, 

143 width: Optional[Union[int, float, str]] = None, 

144 height: Optional[Union[int, float, str]] = None, 

145 aspect_ratio: Optional[Union[int, float, str]] = None, 

146 ): 

147 """ 

148 StorySize constructor. 

149 

150 Args: 

151 width: The width of a presentation story. 

152 height: The height of a presentation story. 

153 aspect_ratio: The aspect ratio of a presentation story. 

154 

155 Raises: 

156 ValueError: If width, height and aspect_ratio are set together. 

157 """ 

158 

159 width = self._convert_to_pixel_or_return(width) 

160 height = self._convert_to_pixel_or_return(height) 

161 

162 self._width = width 

163 self._height = height 

164 self._aspect_ratio = aspect_ratio 

165 

166 self._style = "" 

167 if None not in [width, height, aspect_ratio]: 

168 raise ValueError( 

169 "width, height and aspect ratio cannot be set at the same time" 

170 ) 

171 if all([height is not None, aspect_ratio is not None]): 

172 width = "unset" 

173 if any([width is not None, height is not None, aspect_ratio is not None]): 

174 _width = "" if width is None else f"width: {width};" 

175 _height = "" if height is None else f"height: {height};" 

176 _aspect_ratio = ( 

177 "" 

178 if aspect_ratio is None 

179 else f"aspect-ratio: {aspect_ratio} !important;" 

180 ) 

181 self._style = f"vp.style.cssText = '{_aspect_ratio}{_width}{_height}'" 

182 

183 @staticmethod 

184 def _convert_to_pixel_or_return(value: Any) -> Optional[str]: 

185 if StorySize._is_int(value) or StorySize._is_float(value): 

186 return str(value) + "px" 

187 return value 

188 

189 @staticmethod 

190 def _is_int(value: Any) -> bool: 

191 if isinstance(value, int): 

192 return True 

193 if isinstance(value, str): 

194 if re.search(r"^[-+]?[0-9]+$", value): 

195 return True 

196 return False 

197 

198 @staticmethod 

199 def _is_float(value: Any) -> bool: 

200 if isinstance(value, float): 

201 return True 

202 if isinstance(value, str): 

203 if re.search(r"^[+-]?[0-9]+\.[0-9]+$", value): 

204 return True 

205 return False 

206 

207 @property 

208 def width(self) -> Optional[str]: 

209 """ 

210 A property for storing the width of a presentation story. 

211 

212 Returns: 

213 The width of a presentation story. 

214 """ 

215 

216 return self._width 

217 

218 @property 

219 def height(self) -> Optional[str]: 

220 """ 

221 A property for storing the height of a presentation story. 

222 

223 Returns: 

224 The height of a presentation story. 

225 """ 

226 

227 return self._height 

228 

229 @property 

230 def aspect_ratio(self) -> Optional[Union[int, float, str]]: 

231 """ 

232 A property for storing the aspect ratio of a presentation story. 

233 

234 Returns: 

235 The aspect ratio of a presentation story. 

236 """ 

237 

238 return self._aspect_ratio 

239 

240 @property 

241 def style(self) -> str: 

242 """ 

243 A property for storing the style of a presentation story. 

244 

245 Note: 

246 If neither `width`, `height` nor `aspect_ratio` is set, it returns an empty string. 

247 

248 Returns: 

249 The cssText width and height of a presentation story. 

250 """ 

251 

252 return self._style 

253 

254 @staticmethod 

255 def is_pixel(value: Any) -> bool: 

256 """ 

257 A static method for checking the type of the given value. 

258 

259 Args: 

260 value: The value to check. 

261 

262 Returns: 

263 `True` if the value is pixel, `False` otherwise. 

264 """ 

265 

266 if StorySize._is_int(value) or StorySize._is_float(value): 

267 return True 

268 if isinstance(value, str) and value.endswith("px"): 

269 if StorySize._is_int(value[0:-2]) or StorySize._is_float(value[0:-2]): 

270 return True 

271 return False 

272 

273 def get_width_height_in_pixels(self) -> Tuple[int, int]: 

274 """ 

275 A method for returning the width and height in pixels. 

276 

277 Raises: 

278 ValueError: If width and height are not in pixels when aspect_ratio is not set. 

279 ValueError: If width or height is not in pixel when aspect_ratio is set. 

280 ValueError: If aspect_ratio is not a float when aspect_ratio is set. 

281 

282 Returns: 

283 The width and height in pixels as int. 

284 """ 

285 

286 if self.aspect_ratio is None: 

287 if any( 

288 [ 

289 not StorySize.is_pixel(self.width), 

290 not StorySize.is_pixel(self.height), 

291 ] 

292 ): 

293 raise ValueError(StorySize.ERROR_MSG_WIDTH_AND_HEIGHT) 

294 _width = int(float(self.width[:-2])) # type: ignore 

295 _height = int(float(self.height[:-2])) # type: ignore 

296 else: 

297 if not any( 

298 [ 

299 StorySize._is_int(self.aspect_ratio), 

300 StorySize._is_float(self.aspect_ratio), 

301 ] 

302 ): 

303 raise ValueError(StorySize.ERROR_MSG_ASPECT_RATIO) 

304 if not any( 

305 [StorySize.is_pixel(self.width), StorySize.is_pixel(self.height)] 

306 ): 

307 raise ValueError(StorySize.ERROR_MSG_WIDTH_OR_HEIGHT) 

308 _aspect_ratio = float(self.aspect_ratio) 

309 if StorySize.is_pixel(self.width): 

310 _width = float(self.width[:-2]) # type: ignore 

311 _height = int(_width / _aspect_ratio) 

312 _width = int(_width) 

313 else: 

314 _height = float(self.height[:-2]) # type: ignore 

315 _width = int(_height * _aspect_ratio) 

316 _height = int(_height) 

317 return (_width, _height) 

318 

319 

320class Story(dict): 

321 """A class for representing a presentation story.""" 

322 

323 # pylint: disable=too-many-instance-attributes 

324 

325 def __init__(self, data: Data, style: Optional[Style] = None): 

326 """ 

327 Presentation Story constructor. 

328 

329 Args: 

330 data: Data set for the whole presentation story. 

331 After initialization `data` can not be modified, 

332 but it can be filtered. 

333 style: Style settings for the presentation story. 

334 `style` can be changed at each presentation step. 

335 

336 Raises: 

337 TypeError: If the type of the `data` is not `ipyvizzu.Data`. 

338 TypeError: If the type of the `style` is not `ipyvizzu.Style`. 

339 

340 Example: 

341 Initialize a story with data and without style: 

342 

343 data = Data() 

344 data.add_series("Foo", ["Alice", "Bob", "Ted"]) 

345 data.add_series("Bar", [15, 32, 12]) 

346 data.add_series("Baz", [5, 3, 2]) 

347 

348 story = Story(data=data) 

349 """ 

350 

351 super().__init__() 

352 

353 self._analytics = True 

354 self._vizzu: Optional[str] = None 

355 self._vizzu_story: str = VIZZU_STORY 

356 self._start_slide: Optional[int] = None 

357 

358 self._size: StorySize = StorySize() 

359 

360 self._features: List[str] = [] 

361 self._events: List[str] = [] 

362 self._plugins: List[str] = [] 

363 

364 if not data or type(data) != Data: # pylint: disable=unidiomatic-typecheck 

365 raise TypeError("Type must be Data.") 

366 self.update(data.build()) 

367 

368 if style: 

369 if type(style) != Style: # pylint: disable=unidiomatic-typecheck 

370 raise TypeError("Type must be Style.") 

371 self.update(style.build()) 

372 

373 self["slides"] = [] 

374 

375 @property 

376 def analytics(self) -> bool: 

377 """ 

378 A property for enabling/disabling the usage statistics feature. 

379 

380 The usage statistics feature allows aggregate usage data collection 

381 using Plausible's algorithm. 

382 Enabling this feature helps us follow the progress and overall trends of our library, 

383 allowing us to focus our resources effectively and better serve our users. 

384 

385 We do not track, collect, or store any personal data or personally identifiable information. 

386 All data is isolated to a single day, a single site, and a single device only. 

387 

388 Please note that even when this feature is enabled, 

389 publishing anything made with `ipyvizzu-story` remains GDPR compatible. 

390 

391 Returns: 

392 The value of the property (default `True`). 

393 """ 

394 

395 return self._analytics 

396 

397 @analytics.setter 

398 def analytics(self, analytics: Optional[bool]): 

399 self._analytics = bool(analytics) 

400 

401 @property 

402 def vizzu(self) -> Optional[str]: 

403 """ 

404 A property for changing `vizzu` url. 

405 

406 Note: 

407 If `None`, vizzu url is set by `vizzu-story`. 

408 

409 Returns: 

410 `Vizzu` url. 

411 """ 

412 

413 return self._vizzu 

414 

415 @vizzu.setter 

416 def vizzu(self, url: str) -> None: 

417 self._vizzu = url 

418 

419 @property 

420 def vizzu_story(self) -> str: 

421 """ 

422 A property for changing `vizzu-story` url. 

423 

424 Returns: 

425 `Vizzu-story` url. 

426 """ 

427 

428 return self._vizzu_story 

429 

430 @vizzu_story.setter 

431 def vizzu_story(self, url: str) -> None: 

432 self._vizzu_story = url 

433 

434 @property 

435 def start_slide(self) -> Optional[int]: 

436 """ 

437 A property for setting the starter slide. 

438 

439 Returns: 

440 Number of the starter slide. 

441 """ 

442 

443 return self._start_slide 

444 

445 @start_slide.setter 

446 def start_slide(self, number: int) -> None: 

447 self._start_slide = number 

448 

449 def add_slide(self, slide: Slide) -> None: 

450 """ 

451 A method for adding a slide for the story. 

452 

453 Args: 

454 slide: The next slide of the story. 

455 

456 Raises: 

457 TypeError: If the type of the `slide` is not 

458 [Slide][ipyvizzustory.storylib.story.Slide]. 

459 

460 Example: 

461 Add a slide to the story: 

462 

463 story.add_slide( 

464 Slide( 

465 Step( 

466 Config({"x": "Foo", "y": "Bar"}) 

467 ) 

468 ) 

469 ) 

470 """ 

471 

472 if not slide or type(slide) != Slide: # pylint: disable=unidiomatic-typecheck 

473 raise TypeError("Type must be Slide.") 

474 self["slides"].append(slide) 

475 

476 def set_feature(self, name: str, enabled: bool) -> None: 

477 """ 

478 A method for enabling or disabling a feature of the story. 

479 

480 Args: 

481 name: The name of the feature. 

482 enabled: `True` if enabled or `False` if disabled. 

483 

484 Example: 

485 Set a feature of the story, for example enable the tooltip: 

486 

487 story.set_feature("tooltip", True) 

488 """ 

489 

490 self._features.append(f"chart.feature('{name}', {json.dumps(enabled)});") 

491 

492 def add_event(self, event: str, handler: str) -> None: 

493 """ 

494 A method for creating and turning on an event handler. 

495 

496 Args: 

497 event: The type of the event. 

498 handler: The handler `JavaScript` expression as string. 

499 

500 Example: 

501 Add an event handler to the story: 

502 

503 story.add_event("click", "alert(JSON.stringify(event.detail));") 

504 """ 

505 

506 self._events.append( 

507 f"chart.on('{event}', event => {{{' '.join(handler.split())}}});" 

508 ) 

509 

510 def add_plugin( 

511 self, plugin: str, options: Optional[dict] = None, name: str = "default" 

512 ) -> None: 

513 """ 

514 A method for register plugins of the chart. 

515 

516 Args: 

517 plugin: The package name or the url of the plugin. 

518 options: The plugin constructor options. 

519 name: The name of the plugin (default `default`). 

520 """ 

521 

522 if options is None: 

523 options = {} 

524 

525 self._plugins.append( 

526 "plugins.push({" 

527 + f"plugin: '{plugin}', " 

528 + f"options: {json.dumps(options, cls=RawJavaScriptEncoder)}, " 

529 + f"name: '{name}'" 

530 + "})" 

531 ) 

532 

533 def set_size( 

534 self, 

535 width: Optional[Union[int, float, str]] = None, 

536 height: Optional[Union[int, float, str]] = None, 

537 aspect_ratio: Optional[Union[int, float, str]] = None, 

538 ) -> None: 

539 """ 

540 A method for setting width/height settings. 

541 

542 Args: 

543 width: The width of the presentation story. 

544 height: The height of the presentation story. 

545 aspect_ratio: The aspect ratio of the presentation story. 

546 

547 Example: 

548 Change the size of the story: 

549 

550 story.set_size("100%", "400px") 

551 """ 

552 

553 self._size = StorySize(width=width, height=height, aspect_ratio=aspect_ratio) 

554 

555 def _repr_html_(self) -> str: 

556 return self.to_html() 

557 

558 def to_html(self) -> str: 

559 """ 

560 A method for assembling the `HTML` code. 

561 

562 Returns: 

563 The assembled `HTML` code as string. 

564 """ 

565 

566 vizzu_player_data = f"{json.dumps(self, cls=RawJavaScriptEncoder)}" 

567 return DISPLAY_TEMPLATE.format( 

568 id=uuid.uuid4().hex[:7], 

569 version=__version__, 

570 analytics=str(self._analytics).lower(), 

571 vizzu=f'vizzu-url="{self._vizzu}"' if self._vizzu else "", 

572 vizzu_story=self._vizzu_story, 

573 vizzu_player_data=vizzu_player_data, 

574 start_slide=f'start-slide="{self._start_slide}"' 

575 if self._start_slide 

576 else "", 

577 chart_size=self._size.style, 

578 chart_features=f"\n{DISPLAY_INDENT * 3}".join(self._features), 

579 chart_events=f"\n{DISPLAY_INDENT * 3}".join(self._events), 

580 chart_plugins=f"\n{DISPLAY_INDENT * 3}".join(self._plugins), 

581 ) 

582 

583 def export_to_html(self, filename: PathLike) -> None: 

584 """ 

585 A method for exporting the story into `HTML` file. 

586 

587 Args: 

588 filename: The path of the target `HTML` file. 

589 """ 

590 

591 with open(filename, "w", encoding="utf8") as file_desc: 

592 file_desc.write(self.to_html())