1__license__ = 'GPL v3' 2__copyright__ = '2008, Kovid Goyal <kovid at kovidgoyal.net>' 3 4import os, sys, zipfile, importlib, enum 5 6from calibre.constants import numeric_version, iswindows, ismacos 7from calibre.ptempfile import PersistentTemporaryFile 8 9if iswindows: 10 platform = 'windows' 11elif ismacos: 12 platform = 'osx' 13else: 14 platform = 'linux' 15 16 17class PluginNotFound(ValueError): 18 pass 19 20 21class InvalidPlugin(ValueError): 22 pass 23 24 25class PluginInstallationType(enum.IntEnum): 26 EXTERNAL = 1 27 SYSTEM = 2 28 BUILTIN = 3 29 30 31class Plugin: # {{{ 32 ''' 33 A calibre plugin. Useful members include: 34 35 * ``self.installation_type``: Stores how the plugin was installed. 36 * ``self.plugin_path``: Stores path to the ZIP file that contains 37 this plugin or None if it is a builtin 38 plugin 39 * ``self.site_customization``: Stores a customization string entered 40 by the user. 41 42 Methods that should be overridden in sub classes: 43 44 * :meth:`initialize` 45 * :meth:`customization_help` 46 47 Useful methods: 48 49 * :meth:`temporary_file` 50 * :meth:`__enter__` 51 * :meth:`load_resources` 52 53 ''' 54 #: List of platforms this plugin works on. 55 #: For example: ``['windows', 'osx', 'linux']`` 56 supported_platforms = [] 57 58 #: The name of this plugin. You must set it something other 59 #: than Trivial Plugin for it to work. 60 name = 'Trivial Plugin' 61 62 #: The version of this plugin as a 3-tuple (major, minor, revision) 63 version = (1, 0, 0) 64 65 #: A short string describing what this plugin does 66 description = _('Does absolutely nothing') 67 68 #: The author of this plugin 69 author = _('Unknown') 70 71 #: When more than one plugin exists for a filetype, 72 #: the plugins are run in order of decreasing priority. 73 #: Plugins with higher priority will be run first. 74 #: The highest possible priority is ``sys.maxsize``. 75 #: Default priority is 1. 76 priority = 1 77 78 #: The earliest version of calibre this plugin requires 79 minimum_calibre_version = (0, 4, 118) 80 81 #: The way this plugin is installed 82 installation_type = None 83 84 #: If False, the user will not be able to disable this plugin. Use with 85 #: care. 86 can_be_disabled = True 87 88 #: The type of this plugin. Used for categorizing plugins in the 89 #: GUI 90 type = _('Base') 91 92 def __init__(self, plugin_path): 93 self.plugin_path = plugin_path 94 self.site_customization = None 95 96 def initialize(self): 97 ''' 98 Called once when calibre plugins are initialized. Plugins are 99 re-initialized every time a new plugin is added. Also note that if the 100 plugin is run in a worker process, such as for adding books, then the 101 plugin will be initialized for every new worker process. 102 103 Perform any plugin specific initialization here, such as extracting 104 resources from the plugin ZIP file. The path to the ZIP file is 105 available as ``self.plugin_path``. 106 107 Note that ``self.site_customization`` is **not** available at this point. 108 ''' 109 pass 110 111 def config_widget(self): 112 ''' 113 Implement this method and :meth:`save_settings` in your plugin to 114 use a custom configuration dialog, rather then relying on the simple 115 string based default customization. 116 117 This method, if implemented, must return a QWidget. The widget can have 118 an optional method validate() that takes no arguments and is called 119 immediately after the user clicks OK. Changes are applied if and only 120 if the method returns True. 121 122 If for some reason you cannot perform the configuration at this time, 123 return a tuple of two strings (message, details), these will be 124 displayed as a warning dialog to the user and the process will be 125 aborted. 126 ''' 127 raise NotImplementedError() 128 129 def save_settings(self, config_widget): 130 ''' 131 Save the settings specified by the user with config_widget. 132 133 :param config_widget: The widget returned by :meth:`config_widget`. 134 135 ''' 136 raise NotImplementedError() 137 138 def do_user_config(self, parent=None): 139 ''' 140 This method shows a configuration dialog for this plugin. It returns 141 True if the user clicks OK, False otherwise. The changes are 142 automatically applied. 143 ''' 144 from qt.core import QDialog, QDialogButtonBox, QVBoxLayout, \ 145 QLabel, Qt, QLineEdit 146 from calibre.gui2 import gprefs 147 148 prefname = 'plugin config dialog:'+self.type + ':' + self.name 149 geom = gprefs.get(prefname, None) 150 151 config_dialog = QDialog(parent) 152 button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) 153 v = QVBoxLayout(config_dialog) 154 155 def size_dialog(): 156 if geom is None: 157 config_dialog.resize(config_dialog.sizeHint()) 158 else: 159 from qt.core import QApplication 160 QApplication.instance().safe_restore_geometry(config_dialog, geom) 161 162 button_box.accepted.connect(config_dialog.accept) 163 button_box.rejected.connect(config_dialog.reject) 164 config_dialog.setWindowTitle(_('Customize') + ' ' + self.name) 165 try: 166 config_widget = self.config_widget() 167 except NotImplementedError: 168 config_widget = None 169 170 if isinstance(config_widget, tuple): 171 from calibre.gui2 import warning_dialog 172 warning_dialog(parent, _('Cannot configure'), config_widget[0], 173 det_msg=config_widget[1], show=True) 174 return False 175 176 if config_widget is not None: 177 v.addWidget(config_widget) 178 v.addWidget(button_box) 179 size_dialog() 180 config_dialog.exec() 181 182 if config_dialog.result() == QDialog.DialogCode.Accepted: 183 if hasattr(config_widget, 'validate'): 184 if config_widget.validate(): 185 self.save_settings(config_widget) 186 else: 187 self.save_settings(config_widget) 188 else: 189 from calibre.customize.ui import plugin_customization, \ 190 customize_plugin 191 help_text = self.customization_help(gui=True) 192 help_text = QLabel(help_text, config_dialog) 193 help_text.setWordWrap(True) 194 help_text.setTextInteractionFlags(Qt.TextInteractionFlag.LinksAccessibleByMouse | Qt.TextInteractionFlag.LinksAccessibleByKeyboard) 195 help_text.setOpenExternalLinks(True) 196 v.addWidget(help_text) 197 sc = plugin_customization(self) 198 if not sc: 199 sc = '' 200 sc = sc.strip() 201 sc = QLineEdit(sc, config_dialog) 202 v.addWidget(sc) 203 v.addWidget(button_box) 204 size_dialog() 205 config_dialog.exec() 206 207 if config_dialog.result() == QDialog.DialogCode.Accepted: 208 sc = str(sc.text()).strip() 209 customize_plugin(self, sc) 210 211 geom = bytearray(config_dialog.saveGeometry()) 212 gprefs[prefname] = geom 213 214 return config_dialog.result() 215 216 def load_resources(self, names): 217 ''' 218 If this plugin comes in a ZIP file (user added plugin), this method 219 will allow you to load resources from the ZIP file. 220 221 For example to load an image:: 222 223 pixmap = QPixmap() 224 pixmap.loadFromData(self.load_resources(['images/icon.png'])['images/icon.png']) 225 icon = QIcon(pixmap) 226 227 :param names: List of paths to resources in the ZIP file using / as separator 228 229 :return: A dictionary of the form ``{name: file_contents}``. Any names 230 that were not found in the ZIP file will not be present in the 231 dictionary. 232 233 ''' 234 if self.plugin_path is None: 235 raise ValueError('This plugin was not loaded from a ZIP file') 236 ans = {} 237 with zipfile.ZipFile(self.plugin_path, 'r') as zf: 238 for candidate in zf.namelist(): 239 if candidate in names: 240 ans[candidate] = zf.read(candidate) 241 return ans 242 243 def customization_help(self, gui=False): 244 ''' 245 Return a string giving help on how to customize this plugin. 246 By default raise a :class:`NotImplementedError`, which indicates that 247 the plugin does not require customization. 248 249 If you re-implement this method in your subclass, the user will 250 be asked to enter a string as customization for this plugin. 251 The customization string will be available as 252 ``self.site_customization``. 253 254 Site customization could be anything, for example, the path to 255 a needed binary on the user's computer. 256 257 :param gui: If True return HTML help, otherwise return plain text help. 258 259 ''' 260 raise NotImplementedError() 261 262 def temporary_file(self, suffix): 263 ''' 264 Return a file-like object that is a temporary file on the file system. 265 This file will remain available even after being closed and will only 266 be removed on interpreter shutdown. Use the ``name`` member of the 267 returned object to access the full path to the created temporary file. 268 269 :param suffix: The suffix that the temporary file will have. 270 ''' 271 return PersistentTemporaryFile(suffix) 272 273 def is_customizable(self): 274 try: 275 self.customization_help() 276 return True 277 except NotImplementedError: 278 return False 279 280 def __enter__(self, *args): 281 ''' 282 Add this plugin to the python path so that it's contents become directly importable. 283 Useful when bundling large python libraries into the plugin. Use it like this:: 284 with plugin: 285 import something 286 ''' 287 if self.plugin_path is not None: 288 from calibre.utils.zipfile import ZipFile 289 from importlib.machinery import EXTENSION_SUFFIXES 290 with ZipFile(self.plugin_path) as zf: 291 extensions = {x.lower() for x in EXTENSION_SUFFIXES} 292 zip_safe = True 293 for name in zf.namelist(): 294 for q in extensions: 295 if name.endswith(q): 296 zip_safe = False 297 break 298 if not zip_safe: 299 break 300 if zip_safe: 301 sys.path.append(self.plugin_path) 302 self.sys_insertion_path = self.plugin_path 303 else: 304 from calibre.ptempfile import TemporaryDirectory 305 self._sys_insertion_tdir = TemporaryDirectory('plugin_unzip') 306 self.sys_insertion_path = self._sys_insertion_tdir.__enter__(*args) 307 zf.extractall(self.sys_insertion_path) 308 sys.path.append(self.sys_insertion_path) 309 310 def __exit__(self, *args): 311 ip, it = getattr(self, 'sys_insertion_path', None), getattr(self, 312 '_sys_insertion_tdir', None) 313 if ip in sys.path: 314 sys.path.remove(ip) 315 if hasattr(it, '__exit__'): 316 it.__exit__(*args) 317 318 def cli_main(self, args): 319 ''' 320 This method is the main entry point for your plugins command line 321 interface. It is called when the user does: calibre-debug -r "Plugin 322 Name". Any arguments passed are present in the args variable. 323 ''' 324 raise NotImplementedError('The %s plugin has no command line interface' 325 %self.name) 326 327# }}} 328 329 330class FileTypePlugin(Plugin): # {{{ 331 ''' 332 A plugin that is associated with a particular set of file types. 333 ''' 334 335 #: Set of file types for which this plugin should be run. 336 #: Use '*' for all file types. 337 #: For example: ``{'lit', 'mobi', 'prc'}`` 338 file_types = set() 339 340 #: If True, this plugin is run when books are added 341 #: to the database 342 on_import = False 343 344 #: If True, this plugin is run after books are added 345 #: to the database. In this case the postimport and postadd 346 #: methods of the plugin are called. 347 on_postimport = False 348 349 #: If True, this plugin is run just before a conversion 350 on_preprocess = False 351 352 #: If True, this plugin is run after conversion 353 #: on the final file produced by the conversion output plugin. 354 on_postprocess = False 355 356 type = _('File type') 357 358 def run(self, path_to_ebook): 359 ''' 360 Run the plugin. Must be implemented in subclasses. 361 It should perform whatever modifications are required 362 on the e-book and return the absolute path to the 363 modified e-book. If no modifications are needed, it should 364 return the path to the original e-book. If an error is encountered 365 it should raise an Exception. The default implementation 366 simply return the path to the original e-book. Note that the path to 367 the original file (before any file type plugins are run, is available as 368 self.original_path_to_file). 369 370 The modified e-book file should be created with the 371 :meth:`temporary_file` method. 372 373 :param path_to_ebook: Absolute path to the e-book. 374 375 :return: Absolute path to the modified e-book. 376 ''' 377 # Default implementation does nothing 378 return path_to_ebook 379 380 def postimport(self, book_id, book_format, db): 381 ''' 382 Called post import, i.e., after the book file has been added to the database. Note that 383 this is different from :meth:`postadd` which is called when the book record is created for 384 the first time. This method is called whenever a new file is added to a book record. It is 385 useful for modifying the book record based on the contents of the newly added file. 386 387 :param book_id: Database id of the added book. 388 :param book_format: The file type of the book that was added. 389 :param db: Library database. 390 ''' 391 pass # Default implementation does nothing 392 393 def postadd(self, book_id, fmt_map, db): 394 ''' 395 Called post add, i.e. after a book has been added to the db. Note that 396 this is different from :meth:`postimport`, which is called after a single book file 397 has been added to a book. postadd() is called only when an entire book record 398 with possibly more than one book file has been created for the first time. 399 This is useful if you wish to modify the book record in the database when the 400 book is first added to calibre. 401 402 :param book_id: Database id of the added book. 403 :param fmt_map: Map of file format to path from which the file format 404 was added. Note that this might or might not point to an actual 405 existing file, as sometimes files are added as streams. In which case 406 it might be a dummy value or a non-existent path. 407 :param db: Library database 408 ''' 409 pass # Default implementation does nothing 410 411# }}} 412 413 414class MetadataReaderPlugin(Plugin): # {{{ 415 ''' 416 A plugin that implements reading metadata from a set of file types. 417 ''' 418 #: Set of file types for which this plugin should be run. 419 #: For example: ``set(['lit', 'mobi', 'prc'])`` 420 file_types = set() 421 422 supported_platforms = ['windows', 'osx', 'linux'] 423 version = numeric_version 424 author = 'Kovid Goyal' 425 426 type = _('Metadata reader') 427 428 def __init__(self, *args, **kwargs): 429 Plugin.__init__(self, *args, **kwargs) 430 self.quick = False 431 432 def get_metadata(self, stream, type): 433 ''' 434 Return metadata for the file represented by stream (a file like object 435 that supports reading). Raise an exception when there is an error 436 with the input data. 437 438 :param type: The type of file. Guaranteed to be one of the entries 439 in :attr:`file_types`. 440 :return: A :class:`calibre.ebooks.metadata.book.Metadata` object 441 ''' 442 return None 443# }}} 444 445 446class MetadataWriterPlugin(Plugin): # {{{ 447 ''' 448 A plugin that implements reading metadata from a set of file types. 449 ''' 450 #: Set of file types for which this plugin should be run. 451 #: For example: ``set(['lit', 'mobi', 'prc'])`` 452 file_types = set() 453 454 supported_platforms = ['windows', 'osx', 'linux'] 455 version = numeric_version 456 author = 'Kovid Goyal' 457 458 type = _('Metadata writer') 459 460 def __init__(self, *args, **kwargs): 461 Plugin.__init__(self, *args, **kwargs) 462 self.apply_null = False 463 464 def set_metadata(self, stream, mi, type): 465 ''' 466 Set metadata for the file represented by stream (a file like object 467 that supports reading). Raise an exception when there is an error 468 with the input data. 469 470 :param type: The type of file. Guaranteed to be one of the entries 471 in :attr:`file_types`. 472 :param mi: A :class:`calibre.ebooks.metadata.book.Metadata` object 473 ''' 474 pass 475 476# }}} 477 478 479class CatalogPlugin(Plugin): # {{{ 480 ''' 481 A plugin that implements a catalog generator. 482 ''' 483 484 resources_path = None 485 486 #: Output file type for which this plugin should be run. 487 #: For example: 'epub' or 'xml' 488 file_types = set() 489 490 type = _('Catalog generator') 491 492 #: CLI parser options specific to this plugin, declared as `namedtuple` `Option`: 493 #: 494 #: from collections import namedtuple 495 #: Option = namedtuple('Option', 'option, default, dest, help') 496 #: cli_options = [Option('--catalog-title', default = 'My Catalog', 497 #: dest = 'catalog_title', help = (_('Title of generated catalog. \nDefault:') + " '" + '%default' + "'"))] 498 #: cli_options parsed in calibre.db.cli.cmd_catalog:option_parser() 499 #: 500 cli_options = [] 501 502 def _field_sorter(self, key): 503 ''' 504 Custom fields sort after standard fields 505 ''' 506 if key.startswith('#'): 507 return '~%s' % key[1:] 508 else: 509 return key 510 511 def search_sort_db(self, db, opts): 512 513 db.search(opts.search_text) 514 515 if opts.sort_by: 516 # 2nd arg = ascending 517 db.sort(opts.sort_by, True) 518 return db.get_data_as_dict(ids=opts.ids) 519 520 def get_output_fields(self, db, opts): 521 # Return a list of requested fields 522 all_std_fields = {'author_sort','authors','comments','cover','formats', 523 'id','isbn','library_name','ondevice','pubdate','publisher', 524 'rating','series_index','series','size','tags','timestamp', 525 'title_sort','title','uuid','languages','identifiers'} 526 all_custom_fields = set(db.custom_field_keys()) 527 for field in list(all_custom_fields): 528 fm = db.field_metadata[field] 529 if fm['datatype'] == 'series': 530 all_custom_fields.add(field+'_index') 531 all_fields = all_std_fields.union(all_custom_fields) 532 533 if opts.fields != 'all': 534 # Make a list from opts.fields 535 of = [x.strip() for x in opts.fields.split(',')] 536 requested_fields = set(of) 537 538 # Validate requested_fields 539 if requested_fields - all_fields: 540 from calibre.library import current_library_name 541 invalid_fields = sorted(list(requested_fields - all_fields)) 542 print("invalid --fields specified: %s" % ', '.join(invalid_fields)) 543 print("available fields in '%s': %s" % 544 (current_library_name(), ', '.join(sorted(list(all_fields))))) 545 raise ValueError("unable to generate catalog with specified fields") 546 547 fields = [x for x in of if x in all_fields] 548 else: 549 fields = sorted(all_fields, key=self._field_sorter) 550 551 if not opts.connected_device['is_device_connected'] and 'ondevice' in fields: 552 fields.pop(int(fields.index('ondevice'))) 553 554 return fields 555 556 def initialize(self): 557 ''' 558 If plugin is not a built-in, copy the plugin's .ui and .py files from 559 the ZIP file to $TMPDIR. 560 Tab will be dynamically generated and added to the Catalog Options dialog in 561 calibre.gui2.dialogs.catalog.py:Catalog 562 ''' 563 from calibre.customize.builtins import plugins as builtin_plugins 564 from calibre.customize.ui import config 565 from calibre.ptempfile import PersistentTemporaryDirectory 566 567 if not type(self) in builtin_plugins and self.name not in config['disabled_plugins']: 568 files_to_copy = ["%s.%s" % (self.name.lower(),ext) for ext in ["ui","py"]] 569 resources = zipfile.ZipFile(self.plugin_path,'r') 570 571 if self.resources_path is None: 572 self.resources_path = PersistentTemporaryDirectory('_plugin_resources', prefix='') 573 574 for file in files_to_copy: 575 try: 576 resources.extract(file, self.resources_path) 577 except: 578 print(" customize:__init__.initialize(): %s not found in %s" % (file, os.path.basename(self.plugin_path))) 579 continue 580 resources.close() 581 582 def run(self, path_to_output, opts, db, ids, notification=None): 583 ''' 584 Run the plugin. Must be implemented in subclasses. 585 It should generate the catalog in the format specified 586 in file_types, returning the absolute path to the 587 generated catalog file. If an error is encountered 588 it should raise an Exception. 589 590 The generated catalog file should be created with the 591 :meth:`temporary_file` method. 592 593 :param path_to_output: Absolute path to the generated catalog file. 594 :param opts: A dictionary of keyword arguments 595 :param db: A LibraryDatabase2 object 596 ''' 597 # Default implementation does nothing 598 raise NotImplementedError('CatalogPlugin.generate_catalog() default ' 599 'method, should be overridden in subclass') 600 601# }}} 602 603 604class InterfaceActionBase(Plugin): # {{{ 605 606 supported_platforms = ['windows', 'osx', 'linux'] 607 author = 'Kovid Goyal' 608 type = _('User interface action') 609 can_be_disabled = False 610 611 actual_plugin = None 612 613 def __init__(self, *args, **kwargs): 614 Plugin.__init__(self, *args, **kwargs) 615 self.actual_plugin_ = None 616 617 def load_actual_plugin(self, gui): 618 ''' 619 This method must return the actual interface action plugin object. 620 ''' 621 ac = self.actual_plugin_ 622 if ac is None: 623 mod, cls = self.actual_plugin.split(':') 624 ac = getattr(importlib.import_module(mod), cls)(gui, 625 self.site_customization) 626 self.actual_plugin_ = ac 627 return ac 628 629# }}} 630 631 632class PreferencesPlugin(Plugin): # {{{ 633 634 ''' 635 A plugin representing a widget displayed in the Preferences dialog. 636 637 This plugin has only one important method :meth:`create_widget`. The 638 various fields of the plugin control how it is categorized in the UI. 639 ''' 640 641 supported_platforms = ['windows', 'osx', 'linux'] 642 author = 'Kovid Goyal' 643 type = _('Preferences') 644 can_be_disabled = False 645 646 #: Import path to module that contains a class named ConfigWidget 647 #: which implements the ConfigWidgetInterface. Used by 648 #: :meth:`create_widget`. 649 config_widget = None 650 651 #: Where in the list of categories the :attr:`category` of this plugin should be. 652 category_order = 100 653 654 #: Where in the list of names in a category, the :attr:`gui_name` of this 655 #: plugin should be 656 name_order = 100 657 658 #: The category this plugin should be in 659 category = None 660 661 #: The category name displayed to the user for this plugin 662 gui_category = None 663 664 #: The name displayed to the user for this plugin 665 gui_name = None 666 667 #: The icon for this plugin, should be an absolute path 668 icon = None 669 670 #: The description used for tooltips and the like 671 description = None 672 673 def create_widget(self, parent=None): 674 ''' 675 Create and return the actual Qt widget used for setting this group of 676 preferences. The widget must implement the 677 :class:`calibre.gui2.preferences.ConfigWidgetInterface`. 678 679 The default implementation uses :attr:`config_widget` to instantiate 680 the widget. 681 ''' 682 base, _, wc = self.config_widget.partition(':') 683 if not wc: 684 wc = 'ConfigWidget' 685 base = importlib.import_module(base) 686 widget = getattr(base, wc) 687 return widget(parent) 688 689# }}} 690 691 692class StoreBase(Plugin): # {{{ 693 694 supported_platforms = ['windows', 'osx', 'linux'] 695 author = 'John Schember' 696 type = _('Store') 697 # Information about the store. Should be in the primary language 698 # of the store. This should not be translatable when set by 699 # a subclass. 700 description = _('An e-book store.') 701 minimum_calibre_version = (0, 8, 0) 702 version = (1, 0, 1) 703 704 actual_plugin = None 705 706 # Does the store only distribute e-books without DRM. 707 drm_free_only = False 708 # This is the 2 letter country code for the corporate 709 # headquarters of the store. 710 headquarters = '' 711 # All formats the store distributes e-books in. 712 formats = [] 713 # Is this store on an affiliate program? 714 affiliate = False 715 716 def load_actual_plugin(self, gui): 717 ''' 718 This method must return the actual interface action plugin object. 719 ''' 720 mod, cls = self.actual_plugin.split(':') 721 self.actual_plugin_object = getattr(importlib.import_module(mod), cls)(gui, self.name) 722 return self.actual_plugin_object 723 724 def customization_help(self, gui=False): 725 if getattr(self, 'actual_plugin_object', None) is not None: 726 return self.actual_plugin_object.customization_help(gui) 727 raise NotImplementedError() 728 729 def config_widget(self): 730 if getattr(self, 'actual_plugin_object', None) is not None: 731 return self.actual_plugin_object.config_widget() 732 raise NotImplementedError() 733 734 def save_settings(self, config_widget): 735 if getattr(self, 'actual_plugin_object', None) is not None: 736 return self.actual_plugin_object.save_settings(config_widget) 737 raise NotImplementedError() 738 739# }}} 740 741 742class EditBookToolPlugin(Plugin): # {{{ 743 744 type = _('Edit book tool') 745 minimum_calibre_version = (1, 46, 0) 746 747# }}} 748 749 750class LibraryClosedPlugin(Plugin): # {{{ 751 ''' 752 LibraryClosedPlugins are run when a library is closed, either at shutdown, 753 when the library is changed, or when a library is used in some other way. 754 At the moment these plugins won't be called by the CLI functions. 755 ''' 756 type = _('Library closed') 757 758 # minimum version 2.54 because that is when support was added 759 minimum_calibre_version = (2, 54, 0) 760 761 def run(self, db): 762 ''' 763 The db will be a reference to the new_api (db.cache.py). 764 765 The plugin must run to completion. It must not use the GUI, threads, or 766 any signals. 767 ''' 768 raise NotImplementedError('LibraryClosedPlugin ' 769 'run method must be overridden in subclass') 770# }}} 771