diff --git a/manual/template_lang.rst b/manual/template_lang.rst index f37d45859f..602a9306c9 100644 --- a/manual/template_lang.rst +++ b/manual/template_lang.rst @@ -661,6 +661,7 @@ A PTM template begins with: # 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 + # funcs: allows to use the Builtin/User functions and Stored GPM/Python templates # your Python code goes here return 'a string' @@ -669,6 +670,8 @@ You can add the above text to your template using the context menu, usually acce 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. +The ``context.funcs`` attribute allows to use the Builtin and User functions, and also the Stored GPM/Python templates so that you can exectute them directly in your code. The functions can be retrieve by they name and they name plus a '_' at the end in case of conflict with Python language keywords. + 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 diff --git a/src/calibre/utils/formatter.py b/src/calibre/utils/formatter.py index 4aba476da9..3b016641ad 100644 --- a/src/calibre/utils/formatter.py +++ b/src/calibre/utils/formatter.py @@ -838,7 +838,9 @@ class PythonTemplateContext(object): self.db = None self.arguments = None self.globals = None - self.attrs_set = {'db', 'arguments', 'globals'} + self.formatter = None + self.funcs = None + self.attrs_set = {'db', 'arguments', 'globals', 'funcs'} def set_values(self, **kwargs): # Create/set attributes from the named parameters. Doing it this way we @@ -864,6 +866,89 @@ class PythonTemplateContext(object): return '\n'.join(f'{k}:{v}' for k,v in ans.items()) +class FormatterFuncsCaller(): + ''' + Provides a convenient solution for call the funcs loaded in a TemplateFormatter + The funcs can be called by their name as attribut of this class, plus a _ 'underscore' a the end (Python keyword conflicts) + If the name contain a illegal character for a attribut (like .:-), use getattr() + ''' + + def __init__(self, formatter): + object.__init__(self) + if not isinstance(formatter, TemplateFormatter): + raise ValueError('Class {} is not an instance of TemplateFormatter' + .format(formatter.__class__.__name__)) + self.__formatter__ = formatter + + def __getattribute__(self, name): + if name.startswith('__') and name.endswith('__'): # return internal special attribut + try: + return object.__getattribute__(self, name) + except: + pass + + formatter = self.__formatter__ + func_name = None + if name.endswith('_') and name[:-1] in formatter.funcs: #given the priority to the backup name + func_name = name[:-1] + elif name in formatter.funcs: + func_name = name + + if func_name: + def call(*args, **kargs): + def n(d): + return str('' if d is None else d) + args = [n(a) for a in args] + + try: + def raise_error(msg): + raise ValueError(msg) + if kargs: + raise_error(_('Got an unsupported keyword argument')) + + # special function + if func_name == 'arguments': + raise_error(_('Get the arguments from context.arguments instead of calling arguments()')) + elif func_name == 'globals': + raise_error(_('Get the globals from context.globals instead of calling globals()')) + elif func_name == 'set_globals': + raise_error(_("Set globals using context.globals['name'] = val instead of calling set_globals()")) + elif func_name == 'character': + if _Parser.inlined_function_nodes['character'][0](args): + rslt = _Interpreter.characters.get(args[0], None) + if rslt is None: + raise_error(_("Invalid character name '{0}'").format(args[0])) + else: + raise_error(_('Incorrect number of arguments')) + else: + # builtin/user function and Stored GPM/Python template + func = formatter.funcs[func_name] + if func.object_type == StoredObjectType.PythonFunction: + rslt = func.evaluate(formatter, formatter.kwargs, formatter.book, formatter.locals, *args) + else: + rslt = formatter._eval_sfm_call(func_name, args, formatter.global_vars) + + except Exception as e: + # Change the error message to return this used name on the template + e = e.__class__(_('Error in the function {0} :: {1}').format( + name, + re.sub(r'\w+\.evaluate\(\)\s*', '', str(e), 1))) # remove UserFunction.evaluate() | Builtin*.evaluate() + e.is_internal = True + raise e + return rslt + + return call + + e = AttributeError(_("no function '{}' exists").format(name)) + e.is_internal = True + raise e + + def __dir__(self): + return list(set(object.__dir__(self) + + list(self.__formatter__.funcs.keys()) + + [f+'_' for f in self.__formatter__.funcs.keys()])) + + class _Interpreter: def error(self, message, line_number): m = _('Interpreter: {0} - line number {1}').format(message, line_number) @@ -1490,6 +1575,7 @@ class TemplateFormatter(string.Formatter): self.funcs = formatter_functions().get_functions() self._interpreters = [] self._template_parser = None + self._caller = FormatterFuncsCaller(self) self.recursion_stack = [] self.recursion_level = -1 @@ -1601,10 +1687,21 @@ class TemplateFormatter(string.Formatter): self.python_context_object.set_values( db=get_database(self.book, get_database(self.book, None)), globals=self.global_vars, - arguments=arguments) + arguments=arguments, + formatter=self, + funcs=self._caller) rslt = compiled_template(self.book, self.python_context_object) except Exception as e: - ss = traceback.extract_tb(exc_info()[2])[-1] + stack = traceback.extract_tb(exc_info()[2]) + ss = stack[-1] + if getattr(e, 'is_internal', False): + # Exception raised by FormatterFuncsCaller + # get the line inside the current template instead of the FormatterFuncsCaller + for s in reversed(stack): + if s.filename == '': + ss = s + break + raise ValueError(_('Error in function {0} on line {1} : {2} - {3}').format( ss.name, ss.lineno, type(e).__name__, str(e))) if not isinstance(rslt, str):