Store and access user template functions on a per-library basis. This is used when the DB evaluates composites, avoiding using the global copy.

The ability to pre-compile templates was removed because the one-true list of available functions is known only at execution time. The compiled copy depended on the global state being accurate. And in any event, I was never totally convinced that the compiled version was significantly faster than the pre-lexed version.

Includes a bug fix: the booklist must be refreshed if a user-defined function changed.
This commit is contained in:
Charles Haley 2017-05-15 12:00:09 +02:00 committed by Kovid Goyal
parent c77ffc6075
commit 1aa59276bd
No known key found for this signature in database
GPG Key ID: 06BC317B515ACE7C
7 changed files with 74 additions and 206 deletions

View File

@ -485,16 +485,6 @@ gui_view_history_size = 15
# negative number to increase or decrease the font size. # negative number to increase or decrease the font size.
change_book_details_font_size_by = 0 change_book_details_font_size_by = 0
#: Compile general program mode templates to Python
# Compiled general program mode templates are significantly faster than
# interpreted templates. Setting this tweak to True causes calibre to compile
# (in most cases) general program mode templates. Setting it to False causes
# calibre to use the old behavior -- interpreting the templates. Set the tweak
# to False if some compiled templates produce incorrect values.
# Default: compile_gpm_templates = True
# No compile: compile_gpm_templates = False
compile_gpm_templates = True
#: What format to default to when using the "Unpack book" feature #: What format to default to when using the "Unpack book" feature
# The "Unpack book" feature of calibre allows direct editing of a book format. # The "Unpack book" feature of calibre allows direct editing of a book format.
# If multiple formats are available, calibre will offer you a choice # If multiple formats are available, calibre will offer you a choice

View File

@ -31,7 +31,9 @@ from calibre.utils.filenames import (
WindowsAtomicFolderMove, atomic_rename, remove_dir_if_empty, WindowsAtomicFolderMove, atomic_rename, remove_dir_if_empty,
copytree_using_links, copyfile_using_links) copytree_using_links, copyfile_using_links)
from calibre.utils.img import save_cover_data_to from calibre.utils.img import save_cover_data_to
from calibre.utils.formatter_functions import load_user_template_functions, unload_user_template_functions from calibre.utils.formatter_functions import (load_user_template_functions,
unload_user_template_functions,
compile_user_template_functions)
from calibre.db.tables import (OneToOneTable, ManyToOneTable, ManyToManyTable, from calibre.db.tables import (OneToOneTable, ManyToOneTable, ManyToManyTable,
SizeTable, FormatsTable, AuthorsTable, IdentifiersTable, PathTable, SizeTable, FormatsTable, AuthorsTable, IdentifiersTable, PathTable,
CompositeTable, UUIDTable, RatingTable) CompositeTable, UUIDTable, RatingTable)
@ -316,9 +318,14 @@ class Connection(apsw.Connection): # {{{
# }}} # }}}
def set_global_state(backend): def set_global_state(backend, precompiled_user_functions=None):
load_user_template_functions(backend.library_id, if precompiled_user_functions:
backend.prefs.get('user_template_functions', [])) load_user_template_functions(backend.library_id,
[],
precompiled_user_functions=precompiled_user_functions)
else:
load_user_template_functions(backend.library_id,
backend.prefs.get('user_template_functions', []))
class DB(object): class DB(object):
@ -406,8 +413,16 @@ class DB(object):
self.initialize_prefs(default_prefs, restore_all_prefs, progress_callback) self.initialize_prefs(default_prefs, restore_all_prefs, progress_callback)
self.initialize_custom_columns() self.initialize_custom_columns()
self.initialize_tables() self.initialize_tables()
self.set_user_template_functions(compile_user_template_functions(
self.prefs.get('user_template_functions', [])))
if load_user_formatter_functions: if load_user_formatter_functions:
set_global_state(self) set_global_state(self, precompiled_user_functions = self.get_user_template_functions())
def get_user_template_functions(self):
return self._user_template_functions
def set_user_template_functions(self, user_formatter_functions):
self._user_template_functions = user_formatter_functions
def initialize_prefs(self, default_prefs, restore_all_prefs, progress_callback): # {{{ def initialize_prefs(self, default_prefs, restore_all_prefs, progress_callback): # {{{
self.prefs = DBPrefs(self) self.prefs = DBPrefs(self)

View File

@ -203,6 +203,10 @@ class Cache(object):
def initialize_template_cache(self): def initialize_template_cache(self):
self.formatter_template_cache = {} self.formatter_template_cache = {}
@write_api
def set_user_template_functions(self, user_template_functions):
self.backend.set_user_template_functions(user_template_functions)
@write_api @write_api
def clear_composite_caches(self, book_ids=None): def clear_composite_caches(self, book_ids=None):
for field in self.composites.itervalues(): for field in self.composites.itervalues():
@ -351,12 +355,14 @@ class Cache(object):
bools_are_tristate = self.backend.prefs['bools_are_tristate'] bools_are_tristate = self.backend.prefs['bools_are_tristate']
for field, table in self.backend.tables.iteritems(): for field, table in self.backend.tables.iteritems():
self.fields[field] = create_field(field, table, bools_are_tristate) self.fields[field] = create_field(field, table, bools_are_tristate,
self.backend.get_user_template_functions)
if table.metadata['datatype'] == 'composite': if table.metadata['datatype'] == 'composite':
self.composites[field] = self.fields[field] self.composites[field] = self.fields[field]
self.fields['ondevice'] = create_field('ondevice', self.fields['ondevice'] = create_field('ondevice',
VirtualTable('ondevice'), bools_are_tristate) VirtualTable('ondevice'), bools_are_tristate,
self.backend.get_user_template_functions)
for name, field in self.fields.iteritems(): for name, field in self.fields.iteritems():
if name[0] == '#' and name.endswith('_index'): if name[0] == '#' and name.endswith('_index'):

View File

@ -41,7 +41,7 @@ class Field(object):
is_many_many = False is_many_many = False
is_composite = False is_composite = False
def __init__(self, name, table, bools_are_tristate): def __init__(self, name, table, bools_are_tristate, get_user_formatter_functions):
self.name, self.table = name, table self.name, self.table = name, table
dt = self.metadata['datatype'] dt = self.metadata['datatype']
self.has_text_data = dt in {'text', 'comments', 'series', 'enumeration'} self.has_text_data = dt in {'text', 'comments', 'series', 'enumeration'}
@ -88,6 +88,7 @@ class Field(object):
self.category_formatter = calibre_langcode_to_name self.category_formatter = calibre_langcode_to_name
self.writer = Writer(self) self.writer = Writer(self)
self.series_field = None self.series_field = None
self.get_user_formatter_functions = get_user_formatter_functions
@property @property
def metadata(self): def metadata(self):
@ -203,8 +204,8 @@ class CompositeField(OneToOneField):
is_composite = True is_composite = True
SIZE_SUFFIX_MAP = {suffix:i for i, suffix in enumerate(('', 'K', 'M', 'G', 'T', 'P', 'E'))} SIZE_SUFFIX_MAP = {suffix:i for i, suffix in enumerate(('', 'K', 'M', 'G', 'T', 'P', 'E'))}
def __init__(self, name, table, bools_are_tristate): def __init__(self, name, table, bools_are_tristate, get_user_formatter_functions):
OneToOneField.__init__(self, name, table, bools_are_tristate) OneToOneField.__init__(self, name, table, bools_are_tristate, get_user_formatter_functions)
self._render_cache = {} self._render_cache = {}
self._lock = Lock() self._lock = Lock()
@ -264,7 +265,8 @@ class CompositeField(OneToOneField):
' INTERNAL USE ONLY. DO NOT USE THIS OUTSIDE THIS CLASS! ' ' INTERNAL USE ONLY. DO NOT USE THIS OUTSIDE THIS CLASS! '
ans = formatter.safe_format( ans = formatter.safe_format(
self.metadata['display']['composite_template'], mi, _('TEMPLATE ERROR'), self.metadata['display']['composite_template'], mi, _('TEMPLATE ERROR'),
mi, column_name=self._composite_name, template_cache=template_cache).strip() mi, column_name=self._composite_name, template_cache=template_cache,
user_functions=self.get_user_formatter_functions()).strip()
with self._lock: with self._lock:
self._render_cache[book_id] = ans self._render_cache[book_id] = ans
return ans return ans
@ -347,7 +349,7 @@ class CompositeField(OneToOneField):
class OnDeviceField(OneToOneField): class OnDeviceField(OneToOneField):
def __init__(self, name, table, bools_are_tristate): def __init__(self, name, table, bools_are_tristate, get_user_formatter_functions):
self.name = name self.name = name
self.book_on_device_func = None self.book_on_device_func = None
self.is_multiple = False self.is_multiple = False
@ -733,7 +735,7 @@ class TagsField(ManyToManyField):
return ans return ans
def create_field(name, table, bools_are_tristate): def create_field(name, table, bools_are_tristate, get_user_formatter_functions):
cls = { cls = {
ONE_ONE: OneToOneField, ONE_ONE: OneToOneField,
MANY_ONE: ManyToOneField, MANY_ONE: ManyToOneField,
@ -753,5 +755,5 @@ def create_field(name, table, bools_are_tristate):
cls = CompositeField cls = CompositeField
elif table.metadata['datatype'] == 'series': elif table.metadata['datatype'] == 'series':
cls = SeriesField cls = SeriesField
return cls(name, table, bools_are_tristate) return cls(name, table, bools_are_tristate, get_user_formatter_functions)

View File

@ -14,7 +14,8 @@ from calibre.gui2.preferences import ConfigWidgetBase, test_widget
from calibre.gui2.preferences.template_functions_ui import Ui_Form 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 (formatter_functions, from calibre.utils.formatter_functions import (formatter_functions,
compile_user_function, load_user_template_functions) compile_user_function, compile_user_template_functions,
load_user_template_functions)
class ConfigWidget(ConfigWidgetBase, Ui_Form): class ConfigWidget(ConfigWidgetBase, Ui_Form):
@ -225,7 +226,10 @@ class ConfigWidget(ConfigWidgetBase, Ui_Form):
if name not in self.builtins: if name not in self.builtins:
pref_value.append((cls.name, cls.doc, cls.arg_count, cls.program_text)) pref_value.append((cls.name, cls.doc, cls.arg_count, cls.program_text))
self.db.new_api.set_pref('user_template_functions', pref_value) self.db.new_api.set_pref('user_template_functions', pref_value)
load_user_template_functions(self.db.library_id, pref_value) funcs = compile_user_template_functions(pref_value)
self.db.new_api.set_user_template_functions(funcs)
self.gui.library_view.model().refresh()
load_user_template_functions(self.db.library_id, [], funcs)
return False return False

View File

@ -25,7 +25,7 @@ class _Parser(object):
LEX_CONSTANTS = frozenset([LEX_STR, LEX_NUM]) LEX_CONSTANTS = frozenset([LEX_STR, LEX_NUM])
def __init__(self, val, prog, parent): def __init__(self, val, prog, funcs, parent):
self.lex_pos = 0 self.lex_pos = 0
self.prog = prog[0] self.prog = prog[0]
self.prog_len = len(self.prog) self.prog_len = len(self.prog)
@ -35,6 +35,7 @@ class _Parser(object):
self.parent_kwargs = parent.kwargs self.parent_kwargs = parent.kwargs
self.parent_book = parent.book self.parent_book = parent.book
self.locals = {'$':val} self.locals = {'$':val}
self.funcs = funcs
def error(self, message): def error(self, message):
m = 'Formatter: ' + message + _(' near ') m = 'Formatter: ' + message + _(' near ')
@ -121,14 +122,13 @@ class _Parser(object):
def expr(self): def expr(self):
if self.token_is_id(): if self.token_is_id():
funcs = formatter_functions().get_functions()
# We have an identifier. Determine if it is a function # We have an identifier. Determine if it is a function
id = self.token() id = self.token()
if not self.token_op_is_a_lparen(): if not self.token_op_is_a_lparen():
if self.token_op_is_a_equals(): if self.token_op_is_a_equals():
# classic assignment statement # classic assignment statement
self.consume() self.consume()
cls = funcs['assign'] cls = self.funcs['assign']
return cls.eval_(self.parent, self.parent_kwargs, return cls.eval_(self.parent, self.parent_kwargs,
self.parent_book, self.locals, id, self.expr()) self.parent_book, self.locals, id, self.expr())
val = self.locals.get(id, None) val = self.locals.get(id, None)
@ -139,7 +139,7 @@ class _Parser(object):
# Check if it is a known one. We do this here so error reporting is # Check if it is a known one. We do this here so error reporting is
# better, as it can identify the tokens near the problem. # better, as it can identify the tokens near the problem.
id = id.strip() id = id.strip()
if id not in funcs: if id not in self.funcs:
self.error(_('unknown function {0}').format(id)) self.error(_('unknown function {0}').format(id))
# Eat the paren # Eat the paren
@ -163,7 +163,7 @@ class _Parser(object):
self.error(_('missing closing parenthesis')) self.error(_('missing closing parenthesis'))
# Evaluate the function # Evaluate the function
cls = funcs[id] cls = self.funcs[id]
if cls.arg_count != -1 and len(args) != cls.arg_count: if cls.arg_count != -1 and len(args) != cls.arg_count:
self.error('incorrect number of arguments for function {}'.format(id)) self.error('incorrect number of arguments for function {}'.format(id))
return cls.eval_(self.parent, self.parent_kwargs, return cls.eval_(self.parent, self.parent_kwargs,
@ -174,142 +174,6 @@ class _Parser(object):
else: else:
self.error(_('expression is not function or constant')) self.error(_('expression is not function or constant'))
class _CompileParser(_Parser):
def __init__(self, val, prog, parent, compile_text):
self.lex_pos = 0
self.prog = prog[0]
self.prog_len = len(self.prog)
if prog[1] != '':
self.error(_('failed to scan program. Invalid input {0}').format(prog[1]))
self.parent = parent
self.parent_kwargs = parent.kwargs
self.parent_book = parent.book
self.locals = {'$':val}
self.compile_text = compile_text
def program(self):
if self.compile_text:
t = self.compile_text
self.compile_text = '\n'
self.max_level = 0
val = self.statement()
if not self.token_is_eof():
self.error(_('syntax error - program ends before EOF'))
if self.compile_text:
t += "\targs=[[]"
for i in range(0, self.max_level):
t += ", None"
t += ']'
self.compile_text = t + self.compile_text + "\treturn args[0][0]\n"
return val
def statement(self, level=0):
while True:
val = self.expr(level)
if self.token_is_eof():
return val
if not self.token_op_is_a_semicolon():
return val
self.consume()
if self.token_is_eof():
return val
if self.compile_text:
self.compile_text += "\targs[%d] = list()\n"%(level,)
def expr(self, level):
if self.compile_text:
self.max_level = max(level+1, self.max_level)
if self.token_is_id():
funcs = formatter_functions().get_functions()
# We have an identifier. Determine if it is a function
id = self.token()
if not self.token_op_is_a_lparen():
if self.token_op_is_a_equals():
# classic assignment statement
self.consume()
cls = funcs['assign']
if self.compile_text:
self.compile_text += '\targs[%d] = list()\n'%(level+1,)
val = cls.eval_(self.parent, self.parent_kwargs,
self.parent_book, self.locals, id, self.expr(level+1))
if self.compile_text:
self.compile_text += "\tlocals['%s'] = args[%d][0]\n"%(id, level+1)
self.compile_text += "\targs[%d].append(args[%d][0])\n"%(level, level+1)
return val
val = self.locals.get(id, None)
if val is None:
self.error(_('Unknown identifier ') + id)
if self.compile_text:
self.compile_text += "\targs[%d].append(locals.get('%s'))\n"%(level, id)
return val
# We have a function.
# Check if it is a known one. We do this here so error reporting is
# better, as it can identify the tokens near the problem.
id = id.strip()
if id not in funcs:
self.error(_('unknown function {0}').format(id))
# Eat the paren
self.consume()
args = list()
if self.compile_text:
self.compile_text += '\targs[%d] = list()\n'%(level+1, )
if id == 'field':
val = self.expr(level+1)
val = self.parent.get_value(val, [], self.parent_kwargs)
if self.compile_text:
self.compile_text += "\targs[%d].append(formatter.get_value(args[%d][0], [], kwargs))\n"%(level, level+1)
if self.token() != ')':
self.error(_('missing closing parenthesis'))
return val
while not self.token_op_is_a_rparen():
if id == 'assign' and len(args) == 0:
# Must handle the lvalue semantics of the assign function.
# The first argument is the name of the destination, not
# the value.
if not self.token_is_id():
self.error('assign requires the first parameter be an id')
t = self.token()
args.append(t)
if self.compile_text:
self.compile_text += "\targs[%d].append('%s')\n"%(level+1, t)
else:
# evaluate the argument (recursive call)
args.append(self.statement(level=level+1))
if not self.token_op_is_a_comma():
break
self.consume()
if self.token() != ')':
self.error(_('missing closing parenthesis'))
# Evaluate the function
cls = funcs[id]
if cls.arg_count != -1 and len(args) != cls.arg_count:
self.error('incorrect number of arguments for function {}'.format(id))
if self.compile_text:
self.compile_text += (
"\targs[%d].append(self.__funcs__['%s']"
".eval_(formatter, kwargs, book, locals, *args[%d]))\n")%(level, id, level+1)
return cls.eval_(self.parent, self.parent_kwargs,
self.parent_book, self.locals, *args)
elif self.token_is_constant():
# String or number
v = unicode(self.token())
if self.compile_text:
tv = v.replace("\\", "\\\\")
tv = tv.replace("'", "\\'")
self.compile_text += "\targs[%d].append(unicode('%s'))\n"%(level, tv)
return v
else:
self.error(_('expression is not function or constant'))
compile_counter = 0
class TemplateFormatter(string.Formatter): class TemplateFormatter(string.Formatter):
''' '''
Provides a format function that substitutes '' for any missing value Provides a format function that substitutes '' for any missing value
@ -327,6 +191,7 @@ class TemplateFormatter(string.Formatter):
self.kwargs = None self.kwargs = None
self.strip_results = True self.strip_results = True
self.locals = {} self.locals = {}
self.funcs = formatter_functions().get_functions()
def _do_format(self, val, fmt): def _do_format(self, val, fmt):
if not fmt or not val: if not fmt or not val:
@ -388,36 +253,15 @@ class TemplateFormatter(string.Formatter):
# keep a cache of the lex'ed program under the theory that re-lexing # keep a cache of the lex'ed program under the theory that re-lexing
# is much more expensive than the cache lookup. This is certainly true # is much more expensive than the cache lookup. This is certainly true
# for more than a few tokens, but it isn't clear for simple programs. # for more than a few tokens, but it isn't clear for simple programs.
if tweaks['compile_gpm_templates']: if column_name is not None and self.template_cache is not None:
if column_name is not None and self.template_cache is not None: lprog = self.template_cache.get(column_name, None)
lprog = self.template_cache.get(column_name, None) if not lprog:
if lprog:
return lprog.evaluate(self, self.kwargs, self.book, self.locals)
lprog = self.lex_scanner.scan(prog) lprog = self.lex_scanner.scan(prog)
compile_text = ('__funcs__ = formatter_functions().get_functions()\n' self.template_cache[column_name] = lprog
'def evaluate(self, formatter, kwargs, book, locals):\n'
)
else:
lprog = self.lex_scanner.scan(prog)
compile_text = None
parser = _CompileParser(val, lprog, self, compile_text)
val = parser.program()
if parser.compile_text:
global compile_counter
compile_counter += 1
f = compile_user_function("__A" + str(compile_counter), 'doc', -1, parser.compile_text)
self.template_cache[column_name] = f
else: else:
if column_name is not None and self.template_cache is not None: lprog = self.lex_scanner.scan(prog)
lprog = self.template_cache.get(column_name, None) parser = _Parser(val, lprog, self.funcs, self)
if not lprog: return parser.program()
lprog = self.lex_scanner.scan(prog)
self.template_cache[column_name] = lprog
else:
lprog = self.lex_scanner.scan(prog)
parser = _Parser(val, lprog, self)
val = parser.program()
return val
# ################# Override parent classes methods ##################### # ################# Override parent classes methods #####################
@ -462,10 +306,9 @@ class TemplateFormatter(string.Formatter):
dispfmt = fmt[0:colon] dispfmt = fmt[0:colon]
colon += 1 colon += 1
funcs = formatter_functions().get_functions()
fname = fmt[colon:p].strip() fname = fmt[colon:p].strip()
if fname in funcs: if fname in self.funcs:
func = funcs[fname] func = self.funcs[fname]
if func.arg_count == 2: if func.arg_count == 2:
# only one arg expected. Don't bother to scan. Avoids need # only one arg expected. Don't bother to scan. Avoids need
# for escaping characters # for escaping characters
@ -516,12 +359,15 @@ class TemplateFormatter(string.Formatter):
def safe_format(self, fmt, kwargs, error_value, book, def safe_format(self, fmt, kwargs, error_value, book,
column_name=None, template_cache=None, column_name=None, template_cache=None,
strip_results=True): strip_results=True, user_functions=None):
self.strip_results = strip_results self.strip_results = strip_results
self.column_name = column_name self.column_name = column_name
self.template_cache = template_cache self.template_cache = template_cache
self.kwargs = kwargs self.kwargs = kwargs
self.book = book self.book = book
if user_functions:
self.funcs = formatter_functions().get_builtins().copy()
self.funcs.update(user_functions)
self.composite_values = {} self.composite_values = {}
self.locals = {} self.locals = {}
try: try:

View File

@ -1622,11 +1622,8 @@ class UserFunction(FormatterUserFunction):
cls = locals_['UserFunction'](name, doc, arg_count, eval_func) cls = locals_['UserFunction'](name, doc, arg_count, eval_func)
return cls return cls
def compile_user_template_functions(funcs):
def load_user_template_functions(library_uuid, funcs): compiled_funcs = {}
unload_user_template_functions(library_uuid)
compiled_funcs = []
for func in funcs: for func in funcs:
try: try:
# Force a name conflict to test the logic # Force a name conflict to test the logic
@ -1637,11 +1634,19 @@ def load_user_template_functions(library_uuid, funcs):
# source. This helps ensure that if the function already is defined # source. This helps ensure that if the function already is defined
# then white space differences don't cause them to compare differently # then white space differences don't cause them to compare differently
compiled_funcs.append(compile_user_function(*func)) cls = compile_user_function(*func)
compiled_funcs[cls.name] = cls
except: except:
traceback.print_exc() traceback.print_exc()
formatter_functions().register_functions(library_uuid, compiled_funcs) return compiled_funcs
def load_user_template_functions(library_uuid, funcs, precompiled_user_functions=None):
unload_user_template_functions(library_uuid)
if precompiled_user_functions:
compiled_funcs = precompiled_user_functions
else:
compiled_funcs = compile_user_template_functions(funcs)
formatter_functions().register_functions(library_uuid, compiled_funcs.values())
def unload_user_template_functions(library_uuid): def unload_user_template_functions(library_uuid):
formatter_functions().unregister_functions(library_uuid) formatter_functions().unregister_functions(library_uuid)