diff --git a/manual/template_lang.rst b/manual/template_lang.rst index d69e94f3f1..4040953608 100644 --- a/manual/template_lang.rst +++ b/manual/template_lang.rst @@ -242,12 +242,20 @@ General Program Mode [ elif_expr ] [ 'else' expression_list ] 'fi' condition ::= top_expression elif_expr ::= 'elif' condition 'then' expression_list elif_expr | '' - for_expr ::= 'for' identifier 'in' list_expr + for_expr ::= for_list | for_range + for_list ::= 'for' identifier 'in' list_expr [ 'separator' separator_expr ] ':' expression_list 'rof' + for_range ::= 'for' identifier 'in' range_expr ':' expression_list 'rof' + range_expr ::= 'range' '(' [ start_expr ',' ] stop_expr + [ ',' step_expr [ ',' limit_expr ] ] ')' list_expr ::= top_expression break_expr ::= 'break' continue_expr ::= 'continue' separator_expr ::= top_expression + start_expr ::= top_expression + stop_expr ::= top_expression + step_expr ::= top_expression + limit_expr ::= top_expression Notes: @@ -324,7 +332,7 @@ As a last example, this program returns the value of the ``series`` column if th **For expressions** -The ``for`` expression iterates over a list of values, processing them one at a time. The ``list_expression`` must evaluate to either a metadata field ``lookup name``, for example ``tags`` or ``#genre``, or a list of values. If the result is a valid ``lookup name`` then the field's value is fetched and the separator specified for that field type is used. If the result isn't a valid lookup name then it is assumed to be a list of values. The list is assumed to be separated by commas unless the optional keyword ``separator`` is supplied, in which case the list values must be separated by the result of evaluating the ``separator_expr``. Each value in the list is assigned to the specified variable then the ``expression_list`` is evaluated. You can use ``break`` to jump out of the loop, and ``continue`` to jump to the beginning of the loop for the next iteration. +The ``for`` expression iterates over a list of values, processing them one at a time. The ``list_expression`` must evaluate either to a metadata field ``lookup name`` e.g., ``tags`` or ``#genre``, or to a list of values. The :ref:`range() function ` (see below) generates a list of numbers. If the result is a valid ``lookup name`` then the field's value is fetched and the separator specified for that field type is used. If the result isn't a valid lookup name then it is assumed to be a list of values. The list is assumed to be separated by commas unless the optional keyword ``separator`` is supplied, in which case the list values must be separated by the result of evaluating the ``separator_expr``. A separator cannot be used if the list is generated by ``range()``. Each value in the list is assigned to the specified variable then the ``expression_list`` is evaluated. You can use ``break`` to jump out of the loop, and ``continue`` to jump to the beginning of the loop for the next iteration. Example: This template removes the first hierarchical name for each value in Genre (``#genre``), constructing a list with the new names:: @@ -541,7 +549,20 @@ In `GPM` the functions described in `Single Function Mode` all require an additi * ``not(value)`` -- returns the string "1" if the value is empty, otherwise returns the empty string. This function can usually be replaced with the unary not (``!``) operator. * ``ondevice()`` -- return the string ``'Yes'`` if ``ondevice`` is set, otherwise return the empty string. * ``or(value [, value]*)`` -- returns the string ``'1'`` if any value is not empty, otherwise returns the empty string. You can have as many values as you want. This function can usually be replaced by the ``||`` operator. A reason it cannot be replaced is if short-circuiting will change the results because of side effects. -* ``print(a [, b]*)`` -- prints the arguments to standard output. Unless you start calibre from the command line (``calibre-debug -g``), the output will go to a black hole. The ``print`` function always returns the empty string. +* ``print(a [, b]*)`` -- prints the arguments to standard output. Unless you start calibre from the command line (``calibre-debug -g``), the output will go into a black hole. The ``print`` function always returns its first argument. + +.. _range_function: + +* ``range(start, stop, step, limit)`` -- returns a list of numbers generated by looping over the range specified by the parameters start, stop, and step, with a maximum length of limit. The first value produced is 'start'. Subsequent values ``next_v = current_v + step``. The loop continues while ``next_v < stop`` assuming ``step`` is positive, otherwise while ``next_v > stop``. An empty list is produced if ``start`` fails the test: ``start >= stop`` if ``step`` is positive. The ``limit`` sets the maximum length of the list and has a default of 1000. The parameters ``start``, ``step``, and ``limit`` are optional. Calling ``range()`` with one argument specifies ``stop``. Two arguments specify ``start`` and ``stop``. Three arguments specify ``start``, ``stop``, and ``step``. Four arguments specify ``start``, ``stop``, ``step`` and ``limit``. Examples:: + + range(5) -> '0, 1, 2, 3, 4' + range(0, 5) -> '0, 1, 2, 3, 4' + range(-1, 5) -> '-1, 0, 1, 2, 3, 4' + range(1, 5) -> '1, 2, 3, 4' + range(1, 5, 2) -> '1, 3' + range(1, 5, 2, 5) -> '1, 3' + range(1, 5, 2, 1) -> error(limit exceeded) + * ``raw_field(lookup_name [, optional_default])`` -- returns the metadata field named by ``lookup_name`` without applying any formatting. It evaluates and returns the optional second argument ``optional_default`` if the field's value is undefined (``None``). * ``raw_list(lookup_name, separator)`` -- returns the metadata list named by ``lookup_name`` without applying any formatting or sorting, with the items separated by separator. * ``re_group(value, pattern [, template_for_group]*)`` -- return a string made by applying the regular expression pattern to ``value`` and replacing each matched instance with the the value returned by the corresponding template. In :ref:`Template Program Mode `, like for the ``template`` and the ``eval`` functions, you use ``[[`` for ``{`` and ``]]`` for ``}``. diff --git a/src/calibre/gui2/dialogs/template_dialog.py b/src/calibre/gui2/dialogs/template_dialog.py index 78f099569d..f650dacec8 100644 --- a/src/calibre/gui2/dialogs/template_dialog.py +++ b/src/calibre/gui2/dialogs/template_dialog.py @@ -47,7 +47,7 @@ class TemplateHighlighter(QSyntaxHighlighter): KEYWORDS = ["program", 'if', 'then', 'else', 'elif', 'fi', 'for', 'rof', 'separator', 'break', 'continue', 'return', 'in', 'inlist', - 'def', 'fed'] + 'def', 'fed', 'limit'] def __init__(self, parent=None, builtin_functions=None): super().__init__(parent) diff --git a/src/calibre/utils/formatter.py b/src/calibre/utils/formatter.py index a34783708f..1dfced43c4 100644 --- a/src/calibre/utils/formatter.py +++ b/src/calibre/utils/formatter.py @@ -50,6 +50,7 @@ class Node: NODE_BINARY_STRINGOP = 27 NODE_LOCAL_FUNCTION_DEFINE = 28 NODE_LOCAL_FUNCTION_CALL = 29 + NODE_RANGE = 30 def __init__(self, line_number, name): self.my_line_number = line_number @@ -83,6 +84,18 @@ class ForNode(Node): self.block = block +class RangeNode(Node): + def __init__(self, line_number, variable, start_expr, stop_expr, step_expr, limit_expr, block): + Node.__init__(self, line_number, 'for ...:') + self.node_type = self.NODE_RANGE + self.variable = variable + self.start_expr = start_expr + self.stop_expr = stop_expr + self.step_expr = step_expr + self.limit_expr = limit_expr + self.block = block + + class BreakNode(Node): def __init__(self, line_number): Node.__init__(self, line_number, 'break') @@ -471,12 +484,38 @@ class _Parser: self.error(_("{0} statement: expected '{1}', " "found '{2}'").format('for', 'in', self.token_text())) self.consume() - list_expr = self.top_expr() - if self.token_is('separator'): + if self.token_text() == 'range': + is_list = False + self.consume() + if not self.token_op_is('('): + self.error(_("{0} statement: expected '(', " + "found '{1}'").format('for', self.token_text())) + self.consume() + start_expr = ConstantNode(line_number, '0') + step_expr = ConstantNode(line_number, '1') + limit_expr = None + stop_expr = self.top_expr() + if self.token_op_is(','): + self.consume() + start_expr = stop_expr + stop_expr = self.top_expr() + if self.token_op_is(','): + self.consume() + step_expr = self.top_expr() + if self.token_op_is(','): + self.consume() + limit_expr = self.top_expr() + if not self.token_op_is(')'): + self.error(_("{0} statement: expected ')', " + "found '{1}'").format('for', self.token_text())) self.consume() - separator = self.expr() else: - separator = None + is_list = True + list_expr = self.top_expr() + if self.token_is('separator'): + separator = self.expr() + else: + separator = None if not self.token_op_is(':'): self.error(_("{0} statement: expected '{1}', " "found '{2}'").format('for', ':', self.token_text())) @@ -486,7 +525,9 @@ class _Parser: self.error(_("{0} statement: expected '{1}', " "found '{2}'").format('for', 'rof', self.token_text())) self.consume() - return ForNode(line_number, variable, list_expr, separator, block) + if is_list: + return ForNode(line_number, variable, list_expr, separator, block) + return RangeNode(line_number, variable, start_expr, stop_expr, step_expr, limit_expr, block) def define_function_expression(self): self.consume() @@ -866,6 +907,57 @@ class _Interpreter: except Exception as e: self.error(_("Unhandled exception '{0}'").format(e), line_number) + def do_node_range(self, prog): + line_number = prog.line_number + try: + try: + start_val = int(self.float_deal_with_none(self.expr(prog.start_expr))) + except ValueError: + self.error(_("{0}: {1} must be an integer").format('for', 'start'), line_number) + try: + stop_val = int(self.float_deal_with_none(self.expr(prog.stop_expr))) + except ValueError: + self.error(_("{0}: {1} must be an integer").format('for', 'stop'), line_number) + try: + step_val = int(self.float_deal_with_none(self.expr(prog.step_expr))) + except ValueError: + self.error(_("{0}: {1} must be an integer").format('for', 'step'), line_number) + try: + limit_val = (1000 if prog.limit_expr is None else + int(self.float_deal_with_none(self.expr(prog.limit_expr)))) + except ValueError: + self.error(_("{0}: {1} must be an integer").format('for', 'limit'), line_number) + var = prog.variable + if (self.break_reporter): + self.break_reporter("'for': start value", str(start_val), line_number) + self.break_reporter("'for': stop value", str(stop_val), line_number) + self.break_reporter("'for': step value", str(step_val), line_number) + self.break_reporter("'for': limit value", str(limit_val), line_number) + ret = '' + try: + range_gen = range(start_val, stop_val, step_val) + if len(range_gen) > limit_val: + self.error( + _("{0}: the range length ({1}) is larger than the limit ({2})").format + ('for', str(len(range_gen)), str(limit_val)), line_number) + for x in (str(x) for x in range_gen): + try: + if (self.break_reporter): + self.break_reporter(f"'for': assign to loop index '{var}'", x, line_number) + self.locals[var] = x + ret = self.expression_list(prog.block) + except ContinueExecuted as e: + ret = e.get_value() + except BreakExecuted as e: + ret = e.get_value() + if (self.break_reporter): + self.break_reporter("'for' block value", ret, line_number) + return ret + except (StopException, ValueError) as e: + raise e + except Exception as e: + self.error(_("Unhandled exception '{0}'").format(e), line_number) + def do_node_rvalue(self, prog): try: if (self.break_reporter): @@ -1270,6 +1362,7 @@ class _Interpreter: Node.NODE_CALL_STORED_TEMPLATE: do_node_stored_template_call, Node.NODE_FIRST_NON_EMPTY: do_node_first_non_empty, Node.NODE_FOR: do_node_for, + Node.NODE_RANGE: do_node_range, Node.NODE_GLOBALS: do_node_globals, Node.NODE_SET_GLOBALS: do_node_set_globals, Node.NODE_CONTAINS: do_node_contains, @@ -1378,6 +1471,7 @@ class TemplateFormatter(string.Formatter): (r'(==|!=|<=|<|>=|>)', lambda x,t: (_Parser.LEX_STRING_INFIX, t)), # noqa (r'(if|then|else|elif|fi)\b',lambda x,t: (_Parser.LEX_KEYWORD, t)), # noqa (r'(for|in|rof|separator)\b',lambda x,t: (_Parser.LEX_KEYWORD, t)), # noqa + (r'(separator|limit)\b', lambda x,t: (_Parser.LEX_KEYWORD, t)), # noqa (r'(def|fed|continue)\b', lambda x,t: (_Parser.LEX_KEYWORD, t)), # noqa (r'(return|inlist|break)\b', lambda x,t: (_Parser.LEX_KEYWORD, t)), # noqa (r'(\|\||&&|!|{|})', lambda x,t: (_Parser.LEX_OP, t)), # noqa diff --git a/src/calibre/utils/formatter_functions.py b/src/calibre/utils/formatter_functions.py index 19cee4e652..f4772ad2f7 100644 --- a/src/calibre/utils/formatter_functions.py +++ b/src/calibre/utils/formatter_functions.py @@ -1409,7 +1409,7 @@ class BuiltinListJoin(BuiltinFormatterFunction): " a = list_join('#@#', $authors, '&', $tags, ',');\n" " b = list_join('#@#', a, '#@#', $#genre, ',', $#people, '&')\n" "You can use expressions to generate a list. For example, " - "assume you want items for ``authors`` and ``#genre``, but " + "assume you want items for authors and #genre, but " "with the genre changed to the word 'Genre: ' followed by " "the first letter of the genre, i.e. the genre 'Fiction' " "becomes 'Genre: F'. The following will do that\n" @@ -1452,6 +1452,54 @@ class BuiltinListUnion(BuiltinFormatterFunction): return separator.join(res.values()) +class BuiltinRange(BuiltinFormatterFunction): + name = 'range' + arg_count = -1 + category = 'List manipulation' + __doc__ = doc = _("range(start, stop, step, limit) -- " + "returns a list of numbers generated by looping over the " + "range specified by the parameters start, stop, and step, " + "with a maximum length of limit. The first value produced " + "is 'start'. Subsequent values next_v are " + "current_v+step. The loop continues while " + "next_vstop. An empty list is produced if " + "start fails the test: start>=stop if step " + "is positive. The limit sets the maximum length of " + "the list and has a default of 1000. The parameters " + "start, step, and limit are optional. " + "Calling range() with one argument specifies stop. " + "Two arguments specify start and stop. Three arguments " + "specify start, stop, and step. Four " + "arguments specify start, stop, step and limit. " + "Examples: range(5)->'0,1,2,3,4'. range(0,5)->'0,1,2,3,4'. " + "range(-1,5)->'-1,0,1,2,3,4'. range(1,5)->'1,2,3,4'. " + "range(1,5,2)->'1,3'. range(1,5,2,5)->'1,3'. " + "range(1,5,2,1)->error(limit exceeded).") + + def evaluate(self, formatter, kwargs, mi, locals, *args): + limit_val = 1000 + start_val = 0 + step_val = 1 + if len(args) == 1: + stop_val = int(args[0] if args[0] and args[0] != 'None' else 0) + elif len(args) == 2: + start_val = int(args[0] if args[0] and args[0] != 'None' else 0) + stop_val = int(args[1] if args[1] and args[1] != 'None' else 0) + elif len(args) >= 3: + start_val = int(args[0] if args[0] and args[0] != 'None' else 0) + stop_val = int(args[1] if args[1] and args[1] != 'None' else 0) + step_val = int(args[2] if args[2] and args[2] != 'None' else 0) + if len(args) > 3: + limit_val = int(args[3] if args[3] and args[3] != 'None' else 0) + r = range(start_val, stop_val, step_val) + if len(r) > limit_val: + raise ValueError( + _("{0}: length ({1}) longer than limit ({2})").format( + 'range', len(r), str(limit_val))) + return ', '.join([str(v) for v in r]) + + class BuiltinListRemoveDuplicates(BuiltinFormatterFunction): name = 'list_remove_duplicates' arg_count = 2 @@ -2166,7 +2214,8 @@ _formatter_builtins = [ BuiltinListReGroup(), BuiltinListRemoveDuplicates(), BuiltinListSort(), BuiltinListSplit(), BuiltinListUnion(),BuiltinLookup(), BuiltinLowercase(), BuiltinMod(), BuiltinMultiply(), BuiltinNot(), BuiltinOndevice(), - BuiltinOr(), BuiltinPrint(), BuiltinRatingToStars(), BuiltinRawField(), BuiltinRawList(), + BuiltinOr(), BuiltinPrint(), BuiltinRatingToStars(), BuiltinRange(), + BuiltinRawField(), BuiltinRawList(), BuiltinRe(), BuiltinReGroup(), BuiltinRound(), BuiltinSelect(), BuiltinSeriesSort(), BuiltinSetGlobals(), BuiltinShorten(), BuiltinStrcat(), BuiltinStrcatMax(), BuiltinStrcmp(), BuiltinStrInList(), BuiltinStrlen(), BuiltinSubitems(),