diff --git a/manual/template_lang.rst b/manual/template_lang.rst index a12c8eea4d..c3c46e6e4f 100644 --- a/manual/template_lang.rst +++ b/manual/template_lang.rst @@ -654,25 +654,31 @@ A PTM template begins with: .. code-block:: python python: - def evaluate(book, db, globals, arguments, **kwargs): + def evaluate(book, context): # book is a calibre metadata object - # db is a calibre legacy database object - # globals is the template global variable dictionary - # arguments is a list of arguments if the template is called by a GPM template, otherwise None - # kwargs is a dictionary provided for future use + # context is an instance of calibre.utils.formatter.PythonTemplateContext, + # which (currently) contains the following attributes: + # db: a calibre legacy database object + # globals: the template global variable dictionary + # arguments: is a list of arguments if the template is called by a GPM template, otherwise None - # Python code goes here - return 'a string' + # your Python code goes here + return 'a string' You can add the above text to your template using the context menu, usually accessed with a right click. The comments are not significant and can be removed. You must use python indenting. -Here is an example of a PTM template that produces a list of all the authors for a series. The list is stored in a `Column built from other columns, behaves like tags`. It shows in :guilabel:`Book details` and has the :guilabel:`on separate lines` checked (in :guilabel:`Preferences->Look & feel->Book details`). That option requires the list to be comma-separated. To satisfy that requirement the template converts commas in author names to semicolons then builds a comma-separated list of authors. The authors are then sorted, which is why the template uses author_sort.:: +The context object supports ``str(context)`` that returns a string of the context's contents, and ``context.attributes`` that returns a list of the attribute names in the context. + +Here is an example of a PTM template that produces a list of all the authors for a series. The list is stored in a `Column built from other columns, behaves like tags`. It shows in :guilabel:`Book details` and has the :guilabel:`on separate lines` checked (in :guilabel:`Preferences->Look & feel->Book details`). That option requires the list to be comma-separated. To satisfy that requirement the template converts commas in author names to semicolons then builds a comma-separated list of authors. The authors are then sorted, which is why the template uses author_sort. + +.. code-block:: python python: - def evaluate(book, db, globals, arguments, **kwargs): + def evaluate(book, context): if book.series is None: return '' ans = set() + db = context.db for id_ in db.search_getting_ids(f'series:"={book.series}"', ''): ans.update(v.strip() for v in db.new_api.field_for('author_sort', id_).split('&')) return ', '.join(v.replace(',', ';') for v in sorted(ans)) diff --git a/src/calibre/db/tests/reading.py b/src/calibre/db/tests/reading.py index 12dc79edcc..a57dfbc740 100644 --- a/src/calibre/db/tests/reading.py +++ b/src/calibre/db/tests/reading.py @@ -822,8 +822,8 @@ class ReadingTest(BaseTest): # test counting books matching a search template = '''python: -def evaluate(book, db, **kwargs): - ids = db.new_api.search("series:true") +def evaluate(book, ctx): + ids = ctx.db.new_api.search("series:true") return str(len(ids)) ''' v = formatter.safe_format(template, {}, 'TEMPLATE ERROR', mi) @@ -831,8 +831,8 @@ def evaluate(book, db, **kwargs): # test counting books when none match the search template = '''python: -def evaluate(book, db, **kwargs): - ids = db.new_api.search("series:afafaf") +def evaluate(book, ctx): + ids = ctx.db.new_api.search("series:afafaf") return str(len(ids)) ''' v = formatter.safe_format(template, {}, 'TEMPLATE ERROR', mi) @@ -840,8 +840,8 @@ def evaluate(book, db, **kwargs): # test is_multiple values template = '''python: -def evaluate(book, db, **kwargs): - tags = db.new_api.all_field_names('tags') +def evaluate(book, ctx): + tags = ctx.db.new_api.all_field_names('tags') return ','.join(list(tags)) ''' v = formatter.safe_format(template, {}, 'TEMPLATE ERROR', mi) @@ -855,9 +855,9 @@ def evaluate(book, db, **kwargs): "", 0, '''python: -def evaluate(book, db, globals, arguments): - tags = set(db.new_api.all_field_names('tags')) - tags.add(arguments[0]) +def evaluate(book, ctx): + tags = set(ctx.db.new_api.all_field_names('tags')) + tags.add(ctx.arguments[0]) return ','.join(list(tags)) ''' ]], None) diff --git a/src/calibre/gui2/dialogs/template_dialog.py b/src/calibre/gui2/dialogs/template_dialog.py index 91ed448b73..15bdf4115b 100644 --- a/src/calibre/gui2/dialogs/template_dialog.py +++ b/src/calibre/gui2/dialogs/template_dialog.py @@ -595,17 +595,18 @@ class TemplateDialog(QDialog, Ui_TemplateDialog): m.exec(self.textbox.mapToGlobal(point)) def add_python_template_header_text(self): - self.textbox.setPlainText('python:\n' - 'def evaluate(book, db, globals, arguments, **kwargs):\n' - '\t# book is a calibre metadata object\n' - '\t# db is a calibre legacy database object\n' - '\t# globals is the template global variable dictionary\n' - '\t# arguments is a list of arguments if the template is ' - 'called by a GPM template, otherwise None\n' - '\t# kwargs is a dictionary provided for future use' - '\n\n\t# Python code goes here\n' - "\treturn 'a string'" + - self.textbox.toPlainText()) + self.textbox.setPlainText('''python: +def evaluate(book, context): + # book is a calibre metadata object + # context is an instance of calibre.utils.formatter.PythonTemplateContext, + # which currently contains the following attributes: + # db: a calibre legacy database object + # globals: the template global variable dictionary + # arguments: is a list of arguments if the template is called by a GPM template, otherwise None + + # your Python code goes here + return 'a string' +''') def set_word_wrap(self, to_what): gprefs['gpm_template_editor_word_wrap_mode'] = to_what diff --git a/src/calibre/utils/formatter.py b/src/calibre/utils/formatter.py index 7cfec49d7b..0d46a16889 100644 --- a/src/calibre/utils/formatter.py +++ b/src/calibre/utils/formatter.py @@ -3,12 +3,12 @@ Created on 23 Sep 2010 @author: charles ''' - __license__ = 'GPL v3' __copyright__ = '2010, Kovid Goyal ' __docformat__ = 'restructuredtext en' import re, string, traceback, numbers +from collections import OrderedDict from functools import partial from math import modf from sys import exc_info @@ -830,6 +830,40 @@ class StopException(Exception): super().__init__('Template evaluation stopped') +class PythonTemplateContext(object): + + def __init__(self, **kwargs): + # Set attributes we already know must exist. + self.db = None + self.arguments = None + self.globals = None + + attrs_set = {'db', 'arguments', 'globals'} + + # Create/set attributes from the named parameters. Doing it this way we + # aren't required to change the signature of __init__ if/when we add + # attributes in the future. However, if a user depends upon the + # existence of some attribute and the context creator doesn't supply it + # then the user will get an AttributeError exception. + for k,v in kwargs.items(): + attrs_set.add(k) + setattr(self, k, v) + self.attrs_set = attrs_set + + @property + def attributes(self): + # return a list of attributes in the context object + return sorted(list(self.attrs_set)) + + def __str__(self): + # return a string of the attribute with values separated by newlines + attrs = sorted(list(self.attrs_set)) + ans = OrderedDict() + for k in attrs: + ans[k] = getattr(self, k, None) + return '\n'.join(f'{k}:{v}' for k,v in ans.items()) + + class _Interpreter: def error(self, message, line_number): m = _('Interpreter: {0} - line number {1}').format(message, line_number) @@ -1564,10 +1598,11 @@ class TemplateFormatter(string.Formatter): def _run_python_template(self, compiled_template, arguments): try: - return compiled_template(book=self.book, - db=get_database(self.book, get_database(self.book, None)), - globals=self.global_vars, - arguments=arguments) + return compiled_template(self.book, + PythonTemplateContext( + db=get_database(self.book, get_database(self.book, None)), + globals=self.global_vars, + arguments=arguments)) except Exception as e: ss = traceback.extract_tb(exc_info()[2])[-1] raise ValueError(_('Error in function {0} on line {1} : {2} - {3}').format(