diff --git a/manual/images/python_template_example.png b/manual/images/python_template_example.png
new file mode 100644
index 0000000000..90aaf33762
Binary files /dev/null and b/manual/images/python_template_example.png differ
diff --git a/manual/template_lang.rst b/manual/template_lang.rst
index c677cba8d1..343f5861a9 100644
--- a/manual/template_lang.rst
+++ b/manual/template_lang.rst
@@ -630,7 +630,7 @@ the value of a custom field #genre. You cannot do this in the :ref:`Single Funct
The example shows several things:
-* `TPM` is used if the expression begins with ``:'`` and ends with ``'``. Anything else is assumed to be in :ref:`Single Function Mode
@@ -87,10 +87,12 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): in template processing. You use a stored template in another template as if it were a template function, for example 'some_name(arg1, arg2...)'.
-Stored templates must use General Program Mode -- they must begin with - the text '{0}'. You retrieve arguments passed to a stored template using - the '{1}()' template function, as in '{1}(var1, var2, ...)'. The passed - arguments are copied to the named variables.
+Stored templates must use either General Program Mode -- they must + either begin with the text '{0}' or be {1}. You retrieve arguments + passed to a GPM stored template using the '{2}()' template function, as + in '{2}(var1, var2, ...)'. The passed arguments are copied to the named + variables. Arguments passed to a python template are in the '{2}' + parameter. Arguments are always strings.
For example, this stored template checks if any items are in a list, returning '1' if any are found and '' if not.
@@ -112,7 +114,7 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): See the template language tutorial for more information. ''') - self.st_textBrowser.setHtml(help_text.format('program:', 'arguments')) + self.st_textBrowser.setHtml(help_text.format('program:', 'python templates', 'arguments')) self.st_textBrowser.adjustSize() self.st_show_hide_help_button.clicked.connect(self.st_show_hide_help) self.st_textBrowser_height = self.st_textBrowser.height() @@ -150,14 +152,14 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): self.builtin_source_dict = {} self.funcs = {k:v for k,v in formatter_functions().get_functions().items() - if v.is_python} + if v.object_type is StoredObjectType.PythonFunction} self.builtins = formatter_functions().get_builtins_and_aliases() self.st_funcs = {} try: for v in self.db.prefs.get('user_template_functions', []): - if not function_pref_is_python(v): + if function_object_type(v) is not StoredObjectType.PythonFunction: self.st_funcs.update({function_pref_name(v):compile_user_function(*v)}) except: if question_dialog(self, _('Template functions'), @@ -281,34 +283,53 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): error_dialog(self.gui, _('Template functions'), _('Function not defined'), show=True) - def create_button_clicked(self, use_name=None): - self.changed_signal.emit() - name = use_name if use_name else str(self.function_name.currentText()) - name = name.split(' -- ')[0] + def check_errors_before_save(self, name, for_replace=False): + # Returns True if there is an error if not name: error_dialog(self.gui, _('Template functions'), _('Name cannot be empty'), show=True) - return - if name in self.funcs: + return True + if not for_replace and name in self.funcs: error_dialog(self.gui, _('Template functions'), _('Name %s already used')%(name,), show=True) - return + return True if name in {function_pref_name(v) for v in self.db.prefs.get('user_template_functions', []) - if not function_pref_is_python(v)}: + if function_object_type(v) is not StoredObjectType.PythonFunction}: error_dialog(self.gui, _('Template functions'), _('The name {} is already used for stored template').format(name), show=True) - return + return True if self.argument_count.value() == 0: - box = warning_dialog(self.gui, _('Template functions'), - _('Argument count should be -1 or greater than zero. ' - 'Setting it to zero means that this function cannot ' - 'be used in single function mode.'), det_msg='', - show=False, show_copy_button=False) - box.bb.setStandardButtons(box.bb.standardButtons() | QDialogButtonBox.StandardButton.Cancel) - box.det_msg_toggle.setVisible(False) - if not box.exec(): - return + if not question_dialog(self.gui, _('Template functions'), + _('Setting argument count to to zero means that this ' + 'function cannot be used in single function mode. ' + 'Is this OK?'), + det_msg='', + show_copy_button=False, + default_yes=False, + skip_dialog_name='template_functions_zero_args_warning', + skip_dialog_msg='Ask this question again', + yes_text=_('Save the function'), + no_text=_('Cancel the save')): + print('cancelled') + return True + try: + prog = str(self.program.toPlainText()) + cls = compile_user_function(name, str(self.documentation.toPlainText()), + self.argument_count.value(), prog) + except: + error_dialog(self.gui, _('Template functions'), + _('Exception while compiling function'), show=True, + det_msg=traceback.format_exc()) + return True + return False + + def create_button_clicked(self, use_name=None, need_error_checks=True): + name = use_name if use_name else str(self.function_name.currentText()) + name = name.split(' -- ')[0] + if need_error_checks and self.check_errors_before_save(name, for_replace=False): + return + self.changed_signal.emit() try: prog = str(self.program.toPlainText()) cls = compile_user_function(name, str(self.documentation.toPlainText()), @@ -364,8 +385,10 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): def replace_button_clicked(self): name = str(self.function_name.itemData(self.function_name.currentIndex())) + if self.check_errors_before_save(name, for_replace=True): + return self.delete_button_clicked() - self.create_button_clicked(use_name=name) + self.create_button_clicked(use_name=name, need_error_checks=False) def refresh_gui(self, gui): pass @@ -428,14 +451,14 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form): self.changed_signal.emit() name = use_name if use_name else str(self.te_name.currentText()) for k,v in formatter_functions().get_functions().items(): - if k == name and v.is_python: + if k == name and v.object_type is StoredObjectType.PythonFunction: error_dialog(self.gui, _('Stored templates'), - _('The name {} is already used for template function').format(name), show=True) + _('The name {} is already used by a template function').format(name), show=True) try: prog = str(self.te_textbox.toPlainText()) - if not prog.startswith('program:'): + if not prog.startswith(('program:', 'python:')): error_dialog(self.gui, _('Stored templates'), - _('The stored template must begin with "program:"'), show=True) + _("The stored template must begin with '{0}' or '{1}'").format('program:', 'python:'), show=True) cls = compile_user_function(name, str(self.template_editor.new_doc.toPlainText()), 0, prog) diff --git a/src/calibre/utils/formatter.py b/src/calibre/utils/formatter.py index 938ac696d8..44f9da012e 100644 --- a/src/calibre/utils/formatter.py +++ b/src/calibre/utils/formatter.py @@ -11,12 +11,14 @@ __docformat__ = 'restructuredtext en' import re, string, traceback, numbers from functools import partial from math import modf +from sys import exc_info from calibre import prints from calibre.constants import DEBUG from calibre.ebooks.metadata.book.base import field_metadata from calibre.utils.config import tweaks -from calibre.utils.formatter_functions import formatter_functions +from calibre.utils.formatter_functions import ( + formatter_functions, get_database, function_object_type, StoredObjectType) from calibre.utils.icu import strcmp from polyglot.builtins import error_message @@ -137,7 +139,8 @@ class StoredTemplateCallNode(Node): def __init__(self, line_number, name, function, expression_list): Node.__init__(self, line_number, 'call template: ' + name + '()') self.node_type = self.NODE_CALL_STORED_TEMPLATE - self.function = function + self.name = name + self.function = function # instance of the definition class self.expression_list = expression_list @@ -579,16 +582,20 @@ class _Parser: return LocalFunctionCallNode(self.line_number, name, arguments) def call_expression(self, name, arguments): - subprog = self.funcs[name].cached_parse_tree - if subprog is None: + compiled_func = self.funcs[name].cached_compiled_text + if compiled_func is None: text = self.funcs[name].program_text - if not text.startswith('program:'): - self.error(_("A stored template must begin with '{0}'").format('program:')) - text = text[len('program:'):] - subprog = _Parser().program(self.parent, self.funcs, - self.parent.lex_scanner.scan(text)) - self.funcs[name].cached_parse_tree = subprog - return StoredTemplateCallNode(self.line_number, name, subprog, arguments) + if function_object_type(text) is StoredObjectType.StoredGPMTemplate: + text = text[len('program:'):] + compiled_func = _Parser().program(self.parent, self.funcs, + self.parent.lex_scanner.scan(text)) + elif function_object_type(text) is StoredObjectType.StoredPythonTemplate: + text = text[len('python:'):] + compiled_func = self.parent.compile_python_template(text) + else: + self.error(_("A stored template must begin with '{0}' or {1}").format('program:', 'python:')) + self.funcs[name].cached_compiled_text = compiled_func + return StoredTemplateCallNode(self.line_number, name, self.funcs[name], arguments) def top_expr(self): return self.or_expr() @@ -775,7 +782,7 @@ class _Parser: if id_ in self.local_functions: return self.local_call_expression(id_, arguments) # Check for calling a stored template - if id_ in self.func_names and not self.funcs[id_].is_python: + if id_ in self.func_names and self.funcs[id_].object_type is not StoredObjectType.PythonFunction: return self.call_expression(id_, arguments) # We must have a reference to a formatter function. Check if # the right number of arguments were supplied @@ -846,7 +853,8 @@ class _Interpreter: try: if is_call: - ret = self.do_node_stored_template_call(StoredTemplateCallNode(1, prog, None), args=args) + # prog is an instance of the function definition class + ret = self.do_node_stored_template_call(StoredTemplateCallNode(1, prog.name, prog, None), args=args) else: ret = self.expression_list(prog) except ReturnExecuted as e: @@ -1014,7 +1022,10 @@ class _Interpreter: else: saved_line_number = None try: - val = self.expression_list(prog.function) + if function_object_type(prog.function.program_text) is StoredObjectType.StoredGPMTemplate: + val = self.expression_list(prog.function.cached_compiled_text) + else: + val = self.parent._run_python_template(prog.function.cached_compiled_text, args) except ReturnExecuted as e: val = e.get_value() self.override_line_number = saved_line_number @@ -1526,14 +1537,62 @@ class TemplateFormatter(string.Formatter): def _eval_sfm_call(self, template_name, args, global_vars): func = self.funcs[template_name] - tree = func.cached_parse_tree - if tree is None: - tree = self.gpm_parser.program(self, self.funcs, - self.lex_scanner.scan(func.program_text[len('program:'):])) - func.cached_parse_tree = tree - return self.gpm_interpreter.program(self.funcs, self, tree, None, - is_call=True, args=args, - global_vars=global_vars) + compiled_text = func.cached_compiled_text + if func.object_type is StoredObjectType.StoredGPMTemplate: + if compiled_text is None: + compiled_text = self.gpm_parser.program(self, self.funcs, + self.lex_scanner.scan(func.program_text[len('program:'):])) + func.cached_compiled_text = compiled_text + return self.gpm_interpreter.program(self.funcs, self, func, None, + is_call=True, args=args, + global_vars=global_vars) + elif function_object_type(func) is StoredObjectType.StoredPythonTemplate: + if compiled_text is None: + compiled_text = self.compile_python_template(func.program_text[len('python:'):]) + func.cached_compiled_text = compiled_text + print(args) + return self._run_python_template(compiled_text, args) + + def _eval_python_template(self, template, column_name): + if column_name is not None and self.template_cache is not None: + func = self.template_cache.get(column_name + '::python', None) + if not func: + func = self.compile_python_template(template) + self.template_cache[column_name + '::python'] = func + else: + func = self.compile_python_template(template) + return self._run_python_template(func, arguments=None) + + 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) + except Exception as e: + ss = traceback.extract_tb(exc_info()[2])[-1] + raise ValueError(_('Error in function {0} on line {1} : {2} - {3}').format( + ss.name, ss.lineno, type(e).__name__, str(e))) + + def compile_python_template(self, template): + def replace_func(mo): + return mo.group().replace('\t', ' ') + + prog ='\n'.join([re.sub(r'^\t*', replace_func, line) + for line in template.splitlines()]) + locals_ = {} + if DEBUG and tweaks.get('enable_template_debug_printing', False): + print(prog) + try: + exec(prog, locals_) + func = locals_['evaluate'] + return func + except SyntaxError as e: + raise(ValueError( + _('Syntax error on line {0} column {1}: text {2}').format(e.lineno, e.offset, e.text))) + except KeyError: + raise(ValueError(_("The {0} function is not defined in the template").format('evaluate'))) + # ################# Override parent classes methods ##################### def get_value(self, key, args, kwargs): @@ -1587,7 +1646,7 @@ class TemplateFormatter(string.Formatter): else: args = self.arg_parser.scan(fmt[p+1:])[0] args = [self.backslash_comma_to_comma.sub(',', a) for a in args] - if not func.is_python: + if func.object_type is not StoredObjectType.PythonFunction: args.insert(0, val) val = self._eval_sfm_call(fname, args, self.global_vars) else: @@ -1615,6 +1674,8 @@ class TemplateFormatter(string.Formatter): if fmt.startswith('program:'): ans = self._eval_program(kwargs.get('$', None), fmt[8:], self.column_name, global_vars, break_reporter) + elif fmt.startswith('python:'): + ans = self._eval_python_template(fmt[7:], self.column_name) else: ans = self.vformat(fmt, args, kwargs) if self.strip_results: diff --git a/src/calibre/utils/formatter_functions.py b/src/calibre/utils/formatter_functions.py index 6edf05d77b..0f1238c498 100644 --- a/src/calibre/utils/formatter_functions.py +++ b/src/calibre/utils/formatter_functions.py @@ -14,6 +14,7 @@ __docformat__ = 'restructuredtext en' import inspect, re, traceback, numbers from contextlib import suppress from datetime import datetime, timedelta +from enum import Enum from functools import partial from math import trunc, floor, ceil, modf @@ -28,6 +29,12 @@ from calibre.utils.localization import calibre_langcode_to_name, canonicalize_la from polyglot.builtins import iteritems, itervalues +class StoredObjectType(Enum): + PythonFunction = 1 + StoredGPMTemplate = 2 + StoredPythonTemplate = 3 + + class FormatterFunctions: error_function_body = ('def evaluate(self, formatter, kwargs, mi, locals):\n' @@ -123,6 +130,39 @@ def formatter_functions(): return _ff +def only_in_gui_error(name): + raise ValueError(_('The function {} can be used only in the GUI').format(name)) + + +def get_database(mi, name): + proxy = mi.get('_proxy_metadata', None) + if proxy is None: + if name is not None: + only_in_gui_error(name) + return None + wr = proxy.get('_db', None) + if wr is None: + if name is not None: + raise ValueError(_('In function {}: The database has been closed').format(name)) + return None + cache = wr() + if cache is None: + if name is not None: + raise ValueError(_('In function {}: The database has been closed').format(name)) + return None + wr = getattr(cache, 'library_database_instance', None) + if wr is None: + if name is not None: + only_in_gui_error() + return None + db = wr() + if db is None: + if name is not None: + raise ValueError(_('In function {}: The database has been closed').format(name)) + return None + return db + + class FormatterFunction: doc = _('No documentation provided') @@ -130,7 +170,7 @@ class FormatterFunction: category = 'Unknown' arg_count = 0 aliases = [] - is_python = True + object_type = StoredObjectType.PythonFunction def evaluate(self, formatter, kwargs, mi, locals, *args): raise NotImplementedError() @@ -145,25 +185,10 @@ class FormatterFunction: return str(ret) def only_in_gui_error(self): - raise ValueError(_('The function {} can be used only in the GUI').format(self.name)) + only_in_gui_error(self.name) def get_database(self, mi): - proxy = mi.get('_proxy_metadata', None) - if proxy is None: - self.only_in_gui_error() - wr = proxy.get('_db', None) - if wr is None: - raise ValueError(_('In function {}: The database has been closed').format(self.name)) - cache = wr() - if cache is None: - raise ValueError(_('In function {}: The database has been closed').format(self.name)) - wr = getattr(cache, 'library_database_instance', None) - if wr is None: - self.only_in_gui_error() - db = wr() - if db is None: - raise ValueError(_('In function {}: The database has been closed').format(self.name)) - return db + return get_database(mi, self.name) class BuiltinFormatterFunction(FormatterFunction): @@ -2368,13 +2393,17 @@ _formatter_builtins = [ class FormatterUserFunction(FormatterFunction): - def __init__(self, name, doc, arg_count, program_text, is_python): - self.is_python = is_python + def __init__(self, name, doc, arg_count, program_text, object_type): + self.object_type = object_type self.name = name self.doc = doc self.arg_count = arg_count self.program_text = program_text - self.cached_parse_tree = None + self.cached_compiled_text = None + # Keep this for external code compatibility. Set it to True if we have a + # python template function, otherwise false. This might break something + # if the code depends on stored templates being in GPM. + self.is_python = True if object_type is StoredObjectType.PythonFunction else False def to_pref(self): return [self.name, self.doc, self.arg_count, self.program_text] @@ -2383,13 +2412,20 @@ class FormatterUserFunction(FormatterFunction): tabs = re.compile(r'^\t*') -def function_pref_is_python(pref): - if isinstance(pref, list): - pref = pref[3] - if pref.startswith('def'): - return True - if pref.startswith('program'): - return False +def function_object_type(thing): + # 'thing' can be a preference instance, program text, or an already-compiled function + if isinstance(thing, FormatterUserFunction): + return thing.object_type + if isinstance(thing, list): + text = thing[3] + else: + text = thing + if text.startswith('def'): + return StoredObjectType.PythonFunction + if text.startswith('program'): + return StoredObjectType.StoredGPMTemplate + if text.startswith('python'): + return StoredObjectType.StoredPythonTemplate raise ValueError('Unknown program type in formatter function pref') @@ -2398,8 +2434,9 @@ def function_pref_name(pref): def compile_user_function(name, doc, arg_count, eval_func): - if not function_pref_is_python(eval_func): - return FormatterUserFunction(name, doc, arg_count, eval_func, False) + typ = function_object_type(eval_func) + if typ is not StoredObjectType.PythonFunction: + return FormatterUserFunction(name, doc, arg_count, eval_func, typ) def replace_func(mo): return mo.group().replace('\t', ' ') @@ -2415,7 +2452,7 @@ class UserFunction(FormatterUserFunction): if DEBUG and tweaks.get('enable_template_debug_printing', False): print(prog) exec(prog, locals_) - cls = locals_['UserFunction'](name, doc, arg_count, eval_func, True) + cls = locals_['UserFunction'](name, doc, arg_count, eval_func, typ) return cls @@ -2432,7 +2469,7 @@ def compile_user_template_functions(funcs): # then white space differences don't cause them to compare differently cls = compile_user_function(*func) - cls.is_python = function_pref_is_python(func) + cls.object_type = function_object_type(func) compiled_funcs[cls.name] = cls except Exception: try: