This commit is contained in:
Kovid Goyal 2022-10-11 19:42:47 +05:30
commit 10a51fb804
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
8 changed files with 466 additions and 133 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@ -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: 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 <single_mode>`. * `TPM` is used if the expression begins with ``:'`` and ends with ``'}``. Anything else is assumed to be in :ref:`Single Function Mode <single_mode>`.
* the variable ``$`` stands for the field named in the template: the expression is operating upon, ``#series`` in this case. * the variable ``$`` stands for the field named in the template: the expression is operating upon, ``#series`` in this case.
* functions must be given all their arguments. There is no default value. For example, the standard built-in functions must be given an additional initial parameter indicating the source field. * functions must be given all their arguments. There is no default value. For example, the standard built-in functions must be given an additional initial parameter indicating the source field.
* white space is ignored and can be used anywhere within the expression. * white space is ignored and can be used anywhere within the expression.
@ -642,16 +642,56 @@ In `TPM`, using ``{`` and ``}`` characters in string literals can lead to errors
As with `General Program Mode`, for functions documented under :ref:`Single Function Mode <single_mode>` you must supply the value the function is to act upon as the first parameter in addition to the documented parameters. In `TPM` you can use ``$`` to access the value specified by the ``lookup name`` for the template expression. As with `General Program Mode`, for functions documented under :ref:`Single Function Mode <single_mode>` you must supply the value the function is to act upon as the first parameter in addition to the documented parameters. In `TPM` you can use ``$`` to access the value specified by the ``lookup name`` for the template expression.
Stored general program mode templates .. _python_mode:
Python Template Mode
-----------------------------------
Python Template Mode (PTM) lets you write templates using native python and the `calibre API <https://manual.calibre-ebook.com/develop.html#api-documentation-for-various-parts-of-calibre>`_. The database API will be of most use; further discussion is beyond the scope of this manual. PTM templates are faster and can do more complicated operations but you must know how to write code in python using the calibre API.
A PTM template begins with::
python:
def evaluate(book, db, globals, arguments, **kwargs):
# 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
# 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 `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.::
python:
def evaluate(book, db, globals, arguments, **kwargs):
if book.series is None:
return ''
ans = set()
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))
The output in :guilabel:`Book details` looks like this:
.. image:: images/python_template_example.png
:align: center
:alt: E-book conversion dialog
:class: half-width-img
Stored templates
---------------------------------------- ----------------------------------------
:ref:`General Program Mode <general_mode>` supports saving templates and calling those templates from another template, much like calling stored functions. You save templates using :guilabel:`Preferences->Advanced->Template functions`. More information is provided in that dialog. You call a template the same way you call a function, passing positional arguments if desired. An argument can be any expression. Examples of calling a template, assuming the stored template is named ``foo``: Both :ref:`General Program Mode <general_mode>` and :ref:`Python Template Mode <python_mode>` support saving templates and calling those templates from another template, much like calling stored functions. You save templates using :guilabel:`Preferences->Advanced->Template functions`. More information is provided in that dialog. You call a template the same way you call a function, passing positional arguments if desired. An argument can be any expression. Examples of calling a template, assuming the stored template is named ``foo``:
* ``foo()`` -- call the template passing no arguments. * ``foo()`` -- call the template passing no arguments.
* ``foo(a, b)`` call the template passing the values of the two variables ``a`` and ``b``. * ``foo(a, b)`` call the template passing the values of the two variables ``a`` and ``b``.
* ``foo(if field('series') then field('series_index') else 0 fi)`` -- if the book has a ``series`` then pass the ``series_index``, otherwise pass the value ``0``. * ``foo(if field('series') then field('series_index') else 0 fi)`` -- if the book has a ``series`` then pass the ``series_index``, otherwise pass the value ``0``.
You retrieve the arguments passed in the call to the stored template using the ``arguments`` function. It both declares and initializes local variables, effectively parameters. The variables are positional; they get the value of the parameter given in the call in the same position. If the corresponding parameter is not provided in the call then ``arguments`` assigns that variable the provided default value. If there is no default value then the variable is set to the empty string. For example, the following ``arguments`` function declares 2 variables, ``key``, ``alternate``:: In GPM you retrieve the arguments passed in the call to the stored template using the ``arguments`` function. It both declares and initializes local variables, effectively parameters. The variables are positional; they get the value of the parameter given in the call in the same position. If the corresponding parameter is not provided in the call then ``arguments`` assigns that variable the provided default value. If there is no default value then the variable is set to the empty string. For example, the following ``arguments`` function declares 2 variables, ``key``, ``alternate``::
arguments(key, alternate='series') arguments(key, alternate='series')
@ -661,6 +701,8 @@ Examples, again assuming the stored template is named ``foo``:
* ``foo('series', '#genre')`` the variable ``key`` is assigned the value ``'series'`` and the variable ``alternate`` is assigned the value ``'#genre'``. * ``foo('series', '#genre')`` the variable ``key`` is assigned the value ``'series'`` and the variable ``alternate`` is assigned the value ``'#genre'``.
* ``foo()`` -- the variable ``key`` is assigned the empty string and the variable ``alternate`` is assigned the value ``'series'``. * ``foo()`` -- the variable ``key`` is assigned the empty string and the variable ``alternate`` is assigned the value ``'series'``.
In PTM the arguments are passed in the ``arguments`` parameter, which is a list of strings. There isn't any way to specify default values. You must check the length of the ``arguments`` list to be sure that the number of arguments is what you expect.
An easy way to test stored templates is using the ``Template tester`` dialog. For ease of access give it a keyboard shortcut in :guilabel:`Preferences->Advanced->Keyboard shortcuts->Template tester`. Giving the ``Stored templates`` dialog a shortcut will help switching more rapidly between the tester and editing the stored template's source code. An easy way to test stored templates is using the ``Template tester`` dialog. For ease of access give it a keyboard shortcut in :guilabel:`Preferences->Advanced->Keyboard shortcuts->Template tester`. Giving the ``Stored templates`` dialog a shortcut will help switching more rapidly between the tester and editing the stored template's source code.
Providing additional information to templates Providing additional information to templates

View File

@ -811,3 +811,58 @@ class ReadingTest(BaseTest):
v = formatter.safe_format('program: book_values("rating", "title:true", ",", 0)', {}, 'TEMPLATE ERROR', mi) v = formatter.safe_format('program: book_values("rating", "title:true", ",", 0)', {}, 'TEMPLATE ERROR', mi)
self.assertEqual(set(v.split(',')), {'4', '6'}) self.assertEqual(set(v.split(',')), {'4', '6'})
# }}} # }}}
def test_python_templates(self): # {{{
from calibre.ebooks.metadata.book.formatter import SafeFormat
formatter = SafeFormat()
# need an empty metadata object to pass to the formatter
db = self.init_legacy(self.library_path)
mi = db.get_metadata(1)
# test counting books matching a search
template = '''python:
def evaluate(book, db, **kwargs):
ids = db.new_api.search("series:true")
return str(len(ids))
'''
v = formatter.safe_format(template, {}, 'TEMPLATE ERROR', mi)
self.assertEqual(v, '2')
# test counting books when none match the search
template = '''python:
def evaluate(book, db, **kwargs):
ids = db.new_api.search("series:afafaf")
return str(len(ids))
'''
v = formatter.safe_format(template, {}, 'TEMPLATE ERROR', mi)
self.assertEqual(v, '0')
# test is_multiple values
template = '''python:
def evaluate(book, db, **kwargs):
tags = db.new_api.all_field_names('tags')
return ','.join(list(tags))
'''
v = formatter.safe_format(template, {}, 'TEMPLATE ERROR', mi)
self.assertEqual(set(v.split(',')), {'Tag One', 'News', 'Tag Two'})
# test calling a python stored template from a GPM template
from calibre.utils.formatter_functions import (
load_user_template_functions, unload_user_template_functions)
load_user_template_functions('aaaaa',
[['python_stored_template',
"",
0,
'''python:
def evaluate(book, db, globals, arguments):
tags = set(db.new_api.all_field_names('tags'))
tags.add(arguments[0])
return ','.join(list(tags))
'''
]], None)
v = formatter.safe_format('program: python_stored_template("one argument")', {},
'TEMPLATE ERROR', mi)
unload_user_template_functions('aaaaa')
self.assertEqual(set(v.split(',')), {'Tag One', 'News', 'Tag Two', 'one argument'})
# }}}

View File

@ -23,7 +23,7 @@ from calibre.gui2.dialogs.template_dialog_ui import Ui_TemplateDialog
from calibre.library.coloring import (displayable_columns, color_row_key) from calibre.library.coloring import (displayable_columns, color_row_key)
from calibre.utils.config_base import tweaks from calibre.utils.config_base import tweaks
from calibre.utils.date import DEFAULT_DATE from calibre.utils.date import DEFAULT_DATE
from calibre.utils.formatter_functions import formatter_functions from calibre.utils.formatter_functions import formatter_functions, StoredObjectType
from calibre.utils.formatter import StopException from calibre.utils.formatter import StopException
from calibre.utils.icu import sort_key from calibre.utils.icu import sort_key
from calibre.utils.localization import localize_user_manual_link from calibre.utils.localization import localize_user_manual_link
@ -42,33 +42,56 @@ class ParenPosition:
class TemplateHighlighter(QSyntaxHighlighter): class TemplateHighlighter(QSyntaxHighlighter):
# Code in this class is liberally borrowed from gui2.widgets.PythonHighlighter
BN_FACTOR = 1000 BN_FACTOR = 1000
KEYWORDS = ["program", 'if', 'then', 'else', 'elif', 'fi', 'for', 'rof', KEYWORDS_GPM = ['if', 'then', 'else', 'elif', 'fi', 'for', 'rof',
'separator', 'break', 'continue', 'return', 'in', 'inlist', 'separator', 'break', 'continue', 'return', 'in', 'inlist',
'def', 'fed', 'limit'] 'def', 'fed', 'limit']
KEYWORDS_PYTHON = ["and", "as", "assert", "break", "class", "continue", "def",
"del", "elif", "else", "except", "exec", "finally", "for", "from",
"global", "if", "import", "in", "is", "lambda", "not", "or",
"pass", "print", "raise", "return", "try", "while", "with",
"yield"]
BUILTINS_PYTHON = ["abs", "all", "any", "basestring", "bool", "callable", "chr",
"classmethod", "cmp", "compile", "complex", "delattr", "dict",
"dir", "divmod", "enumerate", "eval", "execfile", "exit", "file",
"filter", "float", "frozenset", "getattr", "globals", "hasattr",
"hex", "id", "int", "isinstance", "issubclass", "iter", "len",
"list", "locals", "long", "map", "max", "min", "object", "oct",
"open", "ord", "pow", "property", "range", "reduce", "repr",
"reversed", "round", "set", "setattr", "slice", "sorted",
"staticmethod", "str", "sum", "super", "tuple", "type", "unichr",
"unicode", "vars", "xrange", "zip"]
CONSTANTS_PYTHON = ["False", "True", "None", "NotImplemented", "Ellipsis"]
def __init__(self, parent=None, builtin_functions=None): def __init__(self, parent=None, builtin_functions=None):
super().__init__(parent) super().__init__(parent)
self.initialize_formats() self.initialize_formats()
self.initialize_rules(builtin_functions) self.initialize_rules(builtin_functions, for_python=False)
self.regenerate_paren_positions() self.regenerate_paren_positions()
self.highlighted_paren = False self.highlighted_paren = False
def initialize_rules(self, builtin_functions): def initialize_rules(self, builtin_functions, for_python=False):
self.for_python = for_python
r = [] r = []
def a(a, b): def a(a, b):
r.append((re.compile(a), b)) r.append((re.compile(a), b))
if not for_python:
a( a(
r"\b[a-zA-Z]\w*\b(?!\(|\s+\()" r"\b[a-zA-Z]\w*\b(?!\(|\s+\()"
r"|\$+#?[a-zA-Z]\w*", r"|\$+#?[a-zA-Z]\w*",
"identifier") "identifier")
a(r"^program:", "keymode")
a( a(
"|".join([r"\b%s\b" % keyword for keyword in self.KEYWORDS]), "|".join([r"\b%s\b" % keyword for keyword in self.KEYWORDS_GPM]),
"keyword") "keyword")
a( a(
@ -77,14 +100,35 @@ class TemplateHighlighter(QSyntaxHighlighter):
formatter_functions().get_builtins())]), formatter_functions().get_builtins())]),
"builtin") "builtin")
a(r"""(?<!:)'[^']*'|"[^"]*\"""", "string")
else:
a(r"^python:", "keymode")
a(
"|".join([r"\b%s\b" % keyword for keyword in self.KEYWORDS_PYTHON]),
"keyword")
a(
"|".join([r"\b%s\b" % builtin for builtin in self.BUILTINS_PYTHON]),
"builtin")
a(
"|".join([r"\b%s\b" % constant for constant in self.CONSTANTS_PYTHON]),
"constant")
a(r"\bPyQt6\b|\bqt.core\b|\bQt?[A-Z][a-z]\w+\b", "pyqt")
a(r"@\w+(\.\w+)?\b", "decorator")
a(r"""('|").*?\1""", "string")
stringRe = r"""((?:"|'){3}).*?\1"""
a(stringRe, "string")
self.stringRe = re.compile(stringRe)
self.tripleSingleRe = re.compile(r"""'''(?!")""")
self.tripleDoubleRe = re.compile(r'''"""(?!')''')
a( a(
r"\b[+-]?[0-9]+[lL]?\b" r"\b[+-]?[0-9]+[lL]?\b"
r"|\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b" r"|\b[+-]?0[xX][0-9A-Fa-f]+[lL]?\b"
r"|\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b", r"|\b[+-]?[0-9]+(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\b",
"number") "number")
a(r"""(?<!:)'[^']*'|"[^"]*\"""", "string")
a(r'\(', "lparen") a(r'\(', "lparen")
a(r'\)', "rparen") a(r'\)', "rparen")
self.Rules = tuple(r) self.Rules = tuple(r)
@ -100,16 +144,23 @@ class TemplateHighlighter(QSyntaxHighlighter):
config = self.Config = {} config = self.Config = {}
config["fontfamily"] = font_name config["fontfamily"] = font_name
app_palette = QApplication.instance().palette() app_palette = QApplication.instance().palette()
for name, color, bold, italic in (
all_formats = (
# name, color, bold, italic
("normal", None, False, False), ("normal", None, False, False),
("keyword", app_palette.color(QPalette.ColorRole.Link).name(), True, False), ("keyword", app_palette.color(QPalette.ColorRole.Link).name(), True, False),
("builtin", app_palette.color(QPalette.ColorRole.Link).name(), False, False), ("builtin", app_palette.color(QPalette.ColorRole.Link).name(), False, False),
("constant", app_palette.color(QPalette.ColorRole.Link).name(), False, False),
("identifier", None, False, True), ("identifier", None, False, True),
("comment", "#007F00", False, True), ("comment", "#007F00", False, True),
("string", "#808000", False, False), ("string", "#808000", False, False),
("number", "#924900", False, False), ("number", "#924900", False, False),
("decorator", "#FF8000", False, True),
("pyqt", None, False, False),
("lparen", None, True, True), ("lparen", None, True, True),
("rparen", None, True, True)): ("rparen", None, True, True))
for name, color, bold, italic in all_formats:
config["%sfontcolor" % name] = color config["%sfontcolor" % name] = color
config["%sfontbold" % name] = bold config["%sfontbold" % name] = bold
config["%sfontitalic" % name] = italic config["%sfontitalic" % name] = italic
@ -119,8 +170,7 @@ class TemplateHighlighter(QSyntaxHighlighter):
base_format.setFontPointSize(config["fontsize"]) base_format.setFontPointSize(config["fontsize"])
self.Formats = {} self.Formats = {}
for name in ("normal", "keyword", "builtin", "comment", "identifier", for name, color, bold, italic in all_formats:
"string", "number", "lparen", "rparen"):
format_ = QTextCharFormat(base_format) format_ = QTextCharFormat(base_format)
color = config["%sfontcolor" % name] color = config["%sfontcolor" % name]
if color: if color:
@ -135,6 +185,8 @@ class TemplateHighlighter(QSyntaxHighlighter):
return self.paren_pos_map.get(dex, None) return self.paren_pos_map.get(dex, None)
def highlightBlock(self, text): def highlightBlock(self, text):
NORMAL, TRIPLESINGLE, TRIPLEDOUBLE = range(3)
bn = self.currentBlock().blockNumber() bn = self.currentBlock().blockNumber()
textLength = len(text) textLength = len(text)
@ -145,6 +197,17 @@ class TemplateHighlighter(QSyntaxHighlighter):
elif text[0] == "#": elif text[0] == "#":
self.setFormat(0, textLength, self.Formats["comment"]) self.setFormat(0, textLength, self.Formats["comment"])
return return
elif self.for_python:
stack = []
for i, c in enumerate(text):
if c in ('"', "'"):
if stack and stack[-1] == c:
stack.pop()
else:
stack.append(c)
elif c == "#" and len(stack) == 0:
self.setFormat(i, len(text), self.Formats["comment"])
return
for regex, format_ in self.Rules: for regex, format_ in self.Rules:
for m in regex.finditer(text): for m in regex.finditer(text):
@ -153,9 +216,31 @@ class TemplateHighlighter(QSyntaxHighlighter):
pp = self.find_paren(bn, i) pp = self.find_paren(bn, i)
if pp and pp.highlight: if pp and pp.highlight:
self.setFormat(i, length, self.Formats[format_]) self.setFormat(i, length, self.Formats[format_])
elif format_ == 'keymode':
if bn > 0 and i == 0:
continue
self.setFormat(i, length, self.Formats['keyword'])
else: else:
self.setFormat(i, length, self.Formats[format_]) self.setFormat(i, length, self.Formats[format_])
self.setCurrentBlockState(NORMAL)
if self.for_python and self.stringRe.search(text) is None:
# This is fooled by triple quotes inside single quoted strings
for m, state in (
(self.tripleSingleRe.search(text), TRIPLESINGLE),
(self.tripleDoubleRe.search(text), TRIPLEDOUBLE)
):
i = -1 if m is None else m.start()
if self.previousBlockState() == state:
if i == -1:
i = len(text)
self.setCurrentBlockState(state)
self.setFormat(0, i + 3, self.Formats["string"])
elif i > -1:
self.setCurrentBlockState(state)
self.setFormat(i, len(text), self.Formats["string"])
if self.generate_paren_positions: if self.generate_paren_positions:
t = str(text) t = str(text)
i = 0 i = 0
@ -327,6 +412,7 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
self.set_mi(mi, fm) self.set_mi(mi, fm)
self.last_text = '' self.last_text = ''
self.highlighting_gpm = True
self.highlighter = TemplateHighlighter(self.textbox.document(), builtin_functions=self.builtins) self.highlighter = TemplateHighlighter(self.textbox.document(), builtin_functions=self.builtins)
self.textbox.cursorPositionChanged.connect(self.text_cursor_changed) self.textbox.cursorPositionChanged.connect(self.text_cursor_changed)
self.textbox.textChanged.connect(self.textbox_changed) self.textbox.textChanged.connect(self.textbox_changed)
@ -494,9 +580,12 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
ca.setIcon(QIcon.ic('ok.png')) ca.setIcon(QIcon.ic('ok.png'))
ca.triggered.connect(partial(self.set_word_wrap, not word_wrapping)) ca.triggered.connect(partial(self.set_word_wrap, not word_wrapping))
m.addSeparator() m.addSeparator()
ca = m.addAction(_('Load template from the Template tester')) ca = m.addAction(_('Add python template definition text'))
ca.triggered.connect(self.load_last_template_text) ca.triggered.connect(self.add_python_template_header_text)
m.addSeparator() m.addSeparator()
ca = m.addAction(_('Load template from the Template tester'))
m.addSeparator()
ca.triggered.connect(self.load_last_template_text)
ca = m.addAction(_('Load template from file')) ca = m.addAction(_('Load template from file'))
ca.setIcon(QIcon.ic('document_open.png')) ca.setIcon(QIcon.ic('document_open.png'))
ca.triggered.connect(self.load_template_from_file) ca.triggered.connect(self.load_template_from_file)
@ -505,6 +594,19 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
ca.triggered.connect(self.save_template) ca.triggered.connect(self.save_template)
m.exec(self.textbox.mapToGlobal(point)) 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())
def set_word_wrap(self, to_what): def set_word_wrap(self, to_what):
gprefs['gpm_template_editor_word_wrap_mode'] = to_what gprefs['gpm_template_editor_word_wrap_mode'] = to_what
self.textbox.setWordWrapMode(QTextOption.WrapMode.WordWrap if to_what else QTextOption.WrapMode.NoWrap) self.textbox.setWordWrapMode(QTextOption.WrapMode.WordWrap if to_what else QTextOption.WrapMode.NoWrap)
@ -673,6 +775,16 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
def textbox_changed(self): def textbox_changed(self):
cur_text = str(self.textbox.toPlainText()) cur_text = str(self.textbox.toPlainText())
if cur_text.startswith('python:'):
if self.highlighting_gpm == True:
self.highlighter.initialize_rules(self.builtins, True)
self.highlighting_gpm = False
self.break_box.setChecked(False)
self.break_box.setEnabled(False)
elif not self.highlighting_gpm:
self.highlighter.initialize_rules(self.builtins, False)
self.highlighting_gpm = True
self.break_box.setEnabled(True)
if self.last_text != cur_text: if self.last_text != cur_text:
self.last_text = cur_text self.last_text = cur_text
self.highlighter.regenerate_paren_positions() self.highlighter.regenerate_paren_positions()
@ -688,7 +800,7 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
w = tv.cellWidget(r, 0) w = tv.cellWidget(r, 0)
w.setText(mi.title) w.setText(mi.title)
w.setCursorPosition(0) w.setCursorPosition(0)
v = SafeFormat().safe_format(txt, mi, _('EXCEPTION: '), v = SafeFormat().safe_format(txt, mi, _('EXCEPTION:'),
mi, global_vars=self.global_vars, mi, global_vars=self.global_vars,
template_functions=self.all_functions, template_functions=self.all_functions,
break_reporter=self.break_reporter if r == break_on_mi else None) break_reporter=self.break_reporter if r == break_on_mi else None)
@ -707,14 +819,15 @@ class TemplateDialog(QDialog, Ui_TemplateDialog):
pos_in_block) pos_in_block)
def function_type_string(self, name, longform=True): def function_type_string(self, name, longform=True):
if self.all_functions[name].is_python: if self.all_functions[name].object_type is StoredObjectType.PythonFunction:
if name in self.builtins: if name in self.builtins:
return (_('Built-in template function') if longform else return (_('Built-in template function') if longform else
_('Built-in function')) _('Built-in function'))
return (_('User defined Python template function') if longform else return (_('User defined Python template function') if longform else
_('User function')) _('User function'))
else: elif self.all_functions[name].object_type is StoredObjectType.StoredPythonTemplate:
return (_('Stored user defined template') if longform else _('Stored template')) return (_('Stored user defined python template') if longform else _('Stored template'))
return (_('Stored user defined GPM template') if longform else _('Stored template'))
def function_changed(self, toWhat): def function_changed(self, toWhat):
name = str(self.function.itemData(toWhat)) name = str(self.function.itemData(toWhat))

View File

@ -344,8 +344,10 @@ you the value as well as all the local variables&lt;/p&gt;</string>
<widget class="CodeEditor" name="textbox"> <widget class="CodeEditor" name="textbox">
<property name="toolTip"> <property name="toolTip">
<string>&lt;p&gt;The text of the template program goes in this box. <string>&lt;p&gt;The text of the template program goes in this box.
Don't forget that a General Program Mode template must begin with A General Program Mode template must begin with the word "program:".
the word "program:".&lt;/p&gt;</string> A python template must begin with the word "python:" followed by a
function definition line. There is a context menu item you can use
to enter the first lines of a python template.&lt;/p&gt;</string>
</property> </property>
<property name="sizePolicy"> <property name="sizePolicy">
<sizepolicy hsizetype="MinimumExpanding" vsizetype="MinimumExpanding"> <sizepolicy hsizetype="MinimumExpanding" vsizetype="MinimumExpanding">

View File

@ -13,7 +13,8 @@ from calibre.gui2.preferences.template_functions_ui import Ui_Form
from calibre.gui2.widgets import PythonHighlighter from calibre.gui2.widgets import PythonHighlighter
from calibre.utils.formatter_functions import ( from calibre.utils.formatter_functions import (
compile_user_function, compile_user_template_functions, formatter_functions, compile_user_function, compile_user_template_functions, formatter_functions,
function_pref_is_python, function_pref_name, load_user_template_functions function_object_type, function_pref_name, load_user_template_functions,
StoredObjectType
) )
from polyglot.builtins import iteritems from polyglot.builtins import iteritems
@ -48,9 +49,8 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
<li><b>your parameters</b>: you must supply one or more formal <li><b>your parameters</b>: you must supply one or more formal
parameters. The number must match the arg count box, unless arg count is parameters. The number must match the arg count box, unless arg count is
-1 (variable number or arguments), in which case the last argument must -1 (variable number or arguments), in which case the last argument must
be *args. At least one argument is required, and is usually the value of be *args. Note that when a function is called in basic template
the field being operated upon. Note that when writing in basic template mode at least one argument is always passed. It is
mode, the user does not provide this first argument. Instead it is
supplied by the formatter.</li> supplied by the formatter.</li>
</ul></p> </ul></p>
<p> <p>
@ -87,10 +87,12 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
in template processing. You use a stored template in another template as in template processing. You use a stored template in another template as
if it were a template function, for example 'some_name(arg1, arg2...)'.</p> if it were a template function, for example 'some_name(arg1, arg2...)'.</p>
<p>Stored templates must use General Program Mode -- they must begin with <p>Stored templates must use either General Program Mode -- they must
the text '{0}'. You retrieve arguments passed to a stored template using either begin with the text '{0}' or be {1}. You retrieve arguments
the '{1}()' template function, as in '{1}(var1, var2, ...)'. The passed passed to a GPM stored template using the '{2}()' template function, as
arguments are copied to the named variables.</p> 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.</p>
<p>For example, this stored template checks if any items are in a <p>For example, this stored template checks if any items are in a
list, returning '1' if any are found and '' if not.</p> list, returning '1' if any are found and '' if not.</p>
@ -112,7 +114,7 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
See the template language tutorial for more information.</p> See the template language tutorial for more information.</p>
</p> </p>
''') ''')
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_textBrowser.adjustSize()
self.st_show_hide_help_button.clicked.connect(self.st_show_hide_help) self.st_show_hide_help_button.clicked.connect(self.st_show_hide_help)
self.st_textBrowser_height = self.st_textBrowser.height() self.st_textBrowser_height = self.st_textBrowser.height()
@ -150,14 +152,14 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
self.builtin_source_dict = {} self.builtin_source_dict = {}
self.funcs = {k:v for k,v in formatter_functions().get_functions().items() 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.builtins = formatter_functions().get_builtins_and_aliases()
self.st_funcs = {} self.st_funcs = {}
try: try:
for v in self.db.prefs.get('user_template_functions', []): 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)}) self.st_funcs.update({function_pref_name(v):compile_user_function(*v)})
except: except:
if question_dialog(self, _('Template functions'), if question_dialog(self, _('Template functions'),
@ -281,34 +283,53 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
error_dialog(self.gui, _('Template functions'), error_dialog(self.gui, _('Template functions'),
_('Function not defined'), show=True) _('Function not defined'), show=True)
def create_button_clicked(self, use_name=None): def check_errors_before_save(self, name, for_replace=False):
self.changed_signal.emit() # Returns True if there is an error
name = use_name if use_name else str(self.function_name.currentText())
name = name.split(' -- ')[0]
if not name: if not name:
error_dialog(self.gui, _('Template functions'), error_dialog(self.gui, _('Template functions'),
_('Name cannot be empty'), show=True) _('Name cannot be empty'), show=True)
return return True
if name in self.funcs: if not for_replace and name in self.funcs:
error_dialog(self.gui, _('Template functions'), error_dialog(self.gui, _('Template functions'),
_('Name %s already used')%(name,), show=True) _('Name %s already used')%(name,), show=True)
return return True
if name in {function_pref_name(v) for v in if name in {function_pref_name(v) for v in
self.db.prefs.get('user_template_functions', []) 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'), error_dialog(self.gui, _('Template functions'),
_('The name {} is already used for stored template').format(name), show=True) _('The name {} is already used for stored template').format(name), show=True)
return return True
if self.argument_count.value() == 0: if self.argument_count.value() == 0:
box = warning_dialog(self.gui, _('Template functions'), if not question_dialog(self.gui, _('Template functions'),
_('Argument count should be -1 or greater than zero. ' _('Setting argument count to to zero means that this '
'Setting it to zero means that this function cannot ' 'function cannot be used in single function mode. '
'be used in single function mode.'), det_msg='', 'Is this OK?'),
show=False, show_copy_button=False) det_msg='',
box.bb.setStandardButtons(box.bb.standardButtons() | QDialogButtonBox.StandardButton.Cancel) show_copy_button=False,
box.det_msg_toggle.setVisible(False) default_yes=False,
if not box.exec(): 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 return
self.changed_signal.emit()
try: try:
prog = str(self.program.toPlainText()) prog = str(self.program.toPlainText())
cls = compile_user_function(name, str(self.documentation.toPlainText()), cls = compile_user_function(name, str(self.documentation.toPlainText()),
@ -364,8 +385,10 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
def replace_button_clicked(self): def replace_button_clicked(self):
name = str(self.function_name.itemData(self.function_name.currentIndex())) 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.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): def refresh_gui(self, gui):
pass pass
@ -428,14 +451,14 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
self.changed_signal.emit() self.changed_signal.emit()
name = use_name if use_name else str(self.te_name.currentText()) name = use_name if use_name else str(self.te_name.currentText())
for k,v in formatter_functions().get_functions().items(): 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'), 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: try:
prog = str(self.te_textbox.toPlainText()) prog = str(self.te_textbox.toPlainText())
if not prog.startswith('program:'): if not prog.startswith(('program:', 'python:')):
error_dialog(self.gui, _('Stored templates'), 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()), cls = compile_user_function(name, str(self.template_editor.new_doc.toPlainText()),
0, prog) 0, prog)

View File

@ -11,12 +11,14 @@ __docformat__ = 'restructuredtext en'
import re, string, traceback, numbers import re, string, traceback, numbers
from functools import partial from functools import partial
from math import modf from math import modf
from sys import exc_info
from calibre import prints from calibre import prints
from calibre.constants import DEBUG from calibre.constants import DEBUG
from calibre.ebooks.metadata.book.base import field_metadata from calibre.ebooks.metadata.book.base import field_metadata
from calibre.utils.config import tweaks 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 calibre.utils.icu import strcmp
from polyglot.builtins import error_message from polyglot.builtins import error_message
@ -137,7 +139,8 @@ class StoredTemplateCallNode(Node):
def __init__(self, line_number, name, function, expression_list): def __init__(self, line_number, name, function, expression_list):
Node.__init__(self, line_number, 'call template: ' + name + '()') Node.__init__(self, line_number, 'call template: ' + name + '()')
self.node_type = self.NODE_CALL_STORED_TEMPLATE 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 self.expression_list = expression_list
@ -579,16 +582,20 @@ class _Parser:
return LocalFunctionCallNode(self.line_number, name, arguments) return LocalFunctionCallNode(self.line_number, name, arguments)
def call_expression(self, name, arguments): def call_expression(self, name, arguments):
subprog = self.funcs[name].cached_parse_tree compiled_func = self.funcs[name].cached_compiled_text
if subprog is None: if compiled_func is None:
text = self.funcs[name].program_text text = self.funcs[name].program_text
if not text.startswith('program:'): if function_object_type(text) is StoredObjectType.StoredGPMTemplate:
self.error(_("A stored template must begin with '{0}'").format('program:'))
text = text[len('program:'):] text = text[len('program:'):]
subprog = _Parser().program(self.parent, self.funcs, compiled_func = _Parser().program(self.parent, self.funcs,
self.parent.lex_scanner.scan(text)) self.parent.lex_scanner.scan(text))
self.funcs[name].cached_parse_tree = subprog elif function_object_type(text) is StoredObjectType.StoredPythonTemplate:
return StoredTemplateCallNode(self.line_number, name, subprog, arguments) 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): def top_expr(self):
return self.or_expr() return self.or_expr()
@ -775,7 +782,7 @@ class _Parser:
if id_ in self.local_functions: if id_ in self.local_functions:
return self.local_call_expression(id_, arguments) return self.local_call_expression(id_, arguments)
# Check for calling a stored template # 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) return self.call_expression(id_, arguments)
# We must have a reference to a formatter function. Check if # We must have a reference to a formatter function. Check if
# the right number of arguments were supplied # the right number of arguments were supplied
@ -846,7 +853,8 @@ class _Interpreter:
try: try:
if is_call: 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: else:
ret = self.expression_list(prog) ret = self.expression_list(prog)
except ReturnExecuted as e: except ReturnExecuted as e:
@ -1014,7 +1022,10 @@ class _Interpreter:
else: else:
saved_line_number = None saved_line_number = None
try: 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: except ReturnExecuted as e:
val = e.get_value() val = e.get_value()
self.override_line_number = saved_line_number 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): def _eval_sfm_call(self, template_name, args, global_vars):
func = self.funcs[template_name] func = self.funcs[template_name]
tree = func.cached_parse_tree compiled_text = func.cached_compiled_text
if tree is None: if func.object_type is StoredObjectType.StoredGPMTemplate:
tree = self.gpm_parser.program(self, self.funcs, if compiled_text is None:
compiled_text = self.gpm_parser.program(self, self.funcs,
self.lex_scanner.scan(func.program_text[len('program:'):])) self.lex_scanner.scan(func.program_text[len('program:'):]))
func.cached_parse_tree = tree func.cached_compiled_text = compiled_text
return self.gpm_interpreter.program(self.funcs, self, tree, None, return self.gpm_interpreter.program(self.funcs, self, func, None,
is_call=True, args=args, is_call=True, args=args,
global_vars=global_vars) 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 ##################### # ################# Override parent classes methods #####################
def get_value(self, key, args, kwargs): def get_value(self, key, args, kwargs):
@ -1587,7 +1646,7 @@ class TemplateFormatter(string.Formatter):
else: else:
args = self.arg_parser.scan(fmt[p+1:])[0] args = self.arg_parser.scan(fmt[p+1:])[0]
args = [self.backslash_comma_to_comma.sub(',', a) for a in args] 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) args.insert(0, val)
val = self._eval_sfm_call(fname, args, self.global_vars) val = self._eval_sfm_call(fname, args, self.global_vars)
else: else:
@ -1615,6 +1674,8 @@ class TemplateFormatter(string.Formatter):
if fmt.startswith('program:'): if fmt.startswith('program:'):
ans = self._eval_program(kwargs.get('$', None), fmt[8:], ans = self._eval_program(kwargs.get('$', None), fmt[8:],
self.column_name, global_vars, break_reporter) self.column_name, global_vars, break_reporter)
elif fmt.startswith('python:'):
ans = self._eval_python_template(fmt[7:], self.column_name)
else: else:
ans = self.vformat(fmt, args, kwargs) ans = self.vformat(fmt, args, kwargs)
if self.strip_results: if self.strip_results:

View File

@ -14,6 +14,7 @@ __docformat__ = 'restructuredtext en'
import inspect, re, traceback, numbers import inspect, re, traceback, numbers
from contextlib import suppress from contextlib import suppress
from datetime import datetime, timedelta from datetime import datetime, timedelta
from enum import Enum
from functools import partial from functools import partial
from math import trunc, floor, ceil, modf 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 from polyglot.builtins import iteritems, itervalues
class StoredObjectType(Enum):
PythonFunction = 1
StoredGPMTemplate = 2
StoredPythonTemplate = 3
class FormatterFunctions: class FormatterFunctions:
error_function_body = ('def evaluate(self, formatter, kwargs, mi, locals):\n' error_function_body = ('def evaluate(self, formatter, kwargs, mi, locals):\n'
@ -123,6 +130,39 @@ def formatter_functions():
return _ff 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: class FormatterFunction:
doc = _('No documentation provided') doc = _('No documentation provided')
@ -130,7 +170,7 @@ class FormatterFunction:
category = 'Unknown' category = 'Unknown'
arg_count = 0 arg_count = 0
aliases = [] aliases = []
is_python = True object_type = StoredObjectType.PythonFunction
def evaluate(self, formatter, kwargs, mi, locals, *args): def evaluate(self, formatter, kwargs, mi, locals, *args):
raise NotImplementedError() raise NotImplementedError()
@ -145,25 +185,10 @@ class FormatterFunction:
return str(ret) return str(ret)
def only_in_gui_error(self): 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): def get_database(self, mi):
proxy = mi.get('_proxy_metadata', None) return get_database(mi, self.name)
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
class BuiltinFormatterFunction(FormatterFunction): class BuiltinFormatterFunction(FormatterFunction):
@ -2368,13 +2393,17 @@ _formatter_builtins = [
class FormatterUserFunction(FormatterFunction): class FormatterUserFunction(FormatterFunction):
def __init__(self, name, doc, arg_count, program_text, is_python): def __init__(self, name, doc, arg_count, program_text, object_type):
self.is_python = is_python self.object_type = object_type
self.name = name self.name = name
self.doc = doc self.doc = doc
self.arg_count = arg_count self.arg_count = arg_count
self.program_text = program_text 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): def to_pref(self):
return [self.name, self.doc, self.arg_count, self.program_text] return [self.name, self.doc, self.arg_count, self.program_text]
@ -2383,13 +2412,20 @@ class FormatterUserFunction(FormatterFunction):
tabs = re.compile(r'^\t*') tabs = re.compile(r'^\t*')
def function_pref_is_python(pref): def function_object_type(thing):
if isinstance(pref, list): # 'thing' can be a preference instance, program text, or an already-compiled function
pref = pref[3] if isinstance(thing, FormatterUserFunction):
if pref.startswith('def'): return thing.object_type
return True if isinstance(thing, list):
if pref.startswith('program'): text = thing[3]
return False 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') 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): def compile_user_function(name, doc, arg_count, eval_func):
if not function_pref_is_python(eval_func): typ = function_object_type(eval_func)
return FormatterUserFunction(name, doc, arg_count, eval_func, False) if typ is not StoredObjectType.PythonFunction:
return FormatterUserFunction(name, doc, arg_count, eval_func, typ)
def replace_func(mo): def replace_func(mo):
return mo.group().replace('\t', ' ') return mo.group().replace('\t', ' ')
@ -2415,7 +2452,7 @@ class UserFunction(FormatterUserFunction):
if DEBUG and tweaks.get('enable_template_debug_printing', False): if DEBUG and tweaks.get('enable_template_debug_printing', False):
print(prog) print(prog)
exec(prog, locals_) 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 return cls
@ -2432,7 +2469,7 @@ def compile_user_template_functions(funcs):
# then white space differences don't cause them to compare differently # then white space differences don't cause them to compare differently
cls = compile_user_function(*func) 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 compiled_funcs[cls.name] = cls
except Exception: except Exception:
try: try: