From b8106534144113fd6074f003fcdb0aca33b5a2d5 Mon Sep 17 00:00:00 2001 From: Charlie Clark Date: Mon, 4 Mar 2019 13:10:37 +0100 Subject: [PATCH 1/4] Added tag 2.6.1 for changeset 134e08ea4301 --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index 54dc0b9a..fa14e738 100644 --- a/.hgtags +++ b/.hgtags @@ -106,3 +106,4 @@ f26ee3951de99bc5b612771b396d6f837e89a701 2.5.11 eee167cb1c26e41a269c6b5ee97e6d6ddcc3b352 2.5.12 516a765c8fa1c2455ec3857c63c0330212d4193e 2.5.14 a275c9c73d563f318bcc125126263757b4ae169c 2.6.0 +134e08ea4301fbb255d0aeb29ffd3c6525c3094d 2.6.1 -- GitLab From 43291145696b64f9f3ea367a337877f0fe9ef524 Mon Sep 17 00:00:00 2001 From: Charlie Clark Date: Fri, 29 Mar 2019 15:31:19 +0100 Subject: [PATCH 2/4] Added tag 2.6.2 for changeset 3640394bff97 --- .hgtags | 1 + 1 file changed, 1 insertion(+) diff --git a/.hgtags b/.hgtags index fa14e738..9e6a1185 100644 --- a/.hgtags +++ b/.hgtags @@ -107,3 +107,4 @@ eee167cb1c26e41a269c6b5ee97e6d6ddcc3b352 2.5.12 516a765c8fa1c2455ec3857c63c0330212d4193e 2.5.14 a275c9c73d563f318bcc125126263757b4ae169c 2.6.0 134e08ea4301fbb255d0aeb29ffd3c6525c3094d 2.6.1 +3640394bff97564a07eb7ecb0cf68f57aaaeac67 2.6.2 -- GitLab From eb8ecccc50d4e75b8f663b84d1ba343fadc4fd51 Mon Sep 17 00:00:00 2001 From: Piti Ongmongkolkul Date: Sat, 14 Sep 2019 14:53:48 +0700 Subject: [PATCH 3/4] treat : as infix operator with backward compatibility for constant range this allows dynamic range of the form function:range etc. Ex: =SUM(OFFSET(A1,1,1):C3 --- openpyxl/formula/tests/test_tokenizer.py | 33 +++++++++++++++----- openpyxl/formula/tests/test_translate.py | 13 +++++--- openpyxl/formula/tokenizer.py | 39 ++++++++++++++++-------- 3 files changed, 60 insertions(+), 25 deletions(-) diff --git a/openpyxl/formula/tests/test_tokenizer.py b/openpyxl/formula/tests/test_tokenizer.py index ab6d95ae..4dac35b3 100644 --- a/openpyxl/formula/tests/test_tokenizer.py +++ b/openpyxl/formula/tests/test_tokenizer.py @@ -2,11 +2,13 @@ from __future__ import absolute_import import pytest + @pytest.fixture def tokenizer(): from .. import tokenizer return tokenizer + # Constants from tokenizer.Token: LITERAL = "LITERAL" OPERAND = "OPERAND" @@ -84,9 +86,9 @@ class TestTokenizerRegexes(object): ("'double'' triple''' quadruple ''''", "'double'' triple'''"), ("'sextuple '''''' and septuple''''''' and more", "'sextuple '''''' and septuple'''''''",), - ("''", "''"), - ("'oops ''", None), - ("gunk'hello world'", None), + ("''", "''"), + ("'oops ''", None), + ("gunk'hello world'", None), ]) def test_link_re(self, tokenizer, string, expected): regex = tokenizer.Tokenizer.STRING_REGEXES["'"] @@ -193,7 +195,7 @@ class TestTokenizer(object): (',', SEP, ARG), ('A6:B6', OPERAND, RANGE), (',', SEP, ARG), - ('$S7', OPERAND, RANGE ), + ('$S7', OPERAND, RANGE), (')', FUNC, CLOSE), (')', FUNC, CLOSE), ('>=', OP_IN, ''), @@ -242,12 +244,27 @@ class TestTokenizer(object): ('A2', OPERAND, RANGE)]), ('=R[41]C[2]', - [('R[41]C[2]', 'OPERAND', 'RANGE')]), + [('R[41]C[2]', 'OPERAND', 'RANGE')],), + + ('=SUM(OFFSET(A1,1,1):C3)', + [('SUM(', 'FUNC', 'OPEN'), + ('OFFSET(', 'FUNC', 'OPEN'), + ('A1', 'OPERAND', 'RANGE'), + (',', 'SEP', 'ARG'), + ('1', 'OPERAND', 'NUMBER'), + (',', 'SEP', 'ARG'), + ('1', 'OPERAND', 'NUMBER'), + (')', 'FUNC', 'CLOSE'), + (':', OP_IN, ''), + ('C3', 'OPERAND', 'RANGE'), + (')', 'FUNC', 'CLOSE') + ]) ]) def test_parse(self, tokenizer, formula, tokens): tok = tokenizer.Tokenizer(formula) result = [(token.value, token.type, token.subtype) for token in tok.items] + assert result == tokens assert tok.render() == formula @@ -261,8 +278,8 @@ class TestTokenizer(object): ("'double'' triple''' quad ''''", 0, "'double'' triple'''"), ("123'sextuple '''''' and septuple''''''' and more", 3, "'sextuple '''''' and septuple'''''''"), - ("''", 0, "''"), - ("'oops ''", 0, None), + ("''", 0, "''"), + ("'oops ''", 0, None), ]) def test_parse_string(self, tokenizer, formula, offset, result): tok = tokenizer.Tokenizer(formula) @@ -372,7 +389,7 @@ class TestTokenizer(object): tok._parse_error() @pytest.mark.parametrize('formula, value', - [(' ' * i, ' ') for i in range(1, 10)] + [('\n', '\n')]) + [(' ' * i, ' ') for i in range(1, 10)] + [('\n', '\n')]) def test_parse_whitespace(self, tokenizer, formula, value): tok = tokenizer.Tokenizer(formula) tok.offset = 0 diff --git a/openpyxl/formula/tests/test_translate.py b/openpyxl/formula/tests/test_translate.py index 3d0d6877..2304f6f1 100644 --- a/openpyxl/formula/tests/test_translate.py +++ b/openpyxl/formula/tests/test_translate.py @@ -2,21 +2,25 @@ from __future__ import absolute_import import pytest + @pytest.fixture def Translator(): from .. import translate return translate.Translator + @pytest.fixture def Tokenizer(): from .. import tokenizer return tokenizer.Tokenizer + @pytest.fixture def TranslatorError(): from .. import translate return translate.TranslatorError + class TestTranslator(object): @pytest.mark.parametrize("origin, row, col", [ @@ -38,9 +42,9 @@ class TestTranslator(object): '=-MAX(Pipeline!AA4:AA138)', '=TEXT(-S7/1000,"$#,##0""M""")', "=IF(A$3<1.3E-8,\"\",IF(ISNA('External Ref'!K7)," + - '"N/A",TEXT(K7*1E+12,"0")&"bp"', + '"N/A",TEXT(K7*1E+12,"0")&"bp"', '=+IF(A$3<>$B7,"",(MIN(IF({TRUE, FALSE;1,2},A6:B6,$S7))>=' + - 'LOWER_BOUND)*($BR6>$S72123))', + 'LOWER_BOUND)*($BR6>$S72123))', '=$A:$A,$C:$C', "Just text" "123.456", @@ -63,7 +67,7 @@ class TestTranslator(object): ["112233:$445566", ("112233", "$445566")], ["A:A", None], ["$ABC:AZZ", None], - ["$DEF:$FOV",None], + ["$DEF:$FOV", None], ["HA:$JA", None], ["named1", None], ["A15", None], @@ -230,11 +234,10 @@ class TestTranslator(object): ("", "A1", "B2", ""), ]) def test_translate_formula_range(self, Translator, formula, - origin, dest, result): + origin, dest, result): trans = Translator(formula, origin) assert trans.translate_formula(dest) == result - def test_translate_formula_coordinates(self, Translator): trans = Translator("='Summary slices'!C3", "A1") result = trans.translate_formula(row_delta=2, col_delta=3) diff --git a/openpyxl/formula/tokenizer.py b/openpyxl/formula/tokenizer.py index 35752a27..640f0755 100644 --- a/openpyxl/formula/tokenizer.py +++ b/openpyxl/formula/tokenizer.py @@ -14,7 +14,6 @@ class TokenizerError(Exception): class Tokenizer(object): - """ A tokenizer for Excel worksheet formulae. @@ -40,14 +39,15 @@ class Tokenizer(object): } ERROR_CODES = ("#NULL!", "#DIV/0!", "#VALUE!", "#REF!", "#NAME?", "#NUM!", "#N/A", "#GETTING_DATA") - TOKEN_ENDERS = ',;}) +-*/^&=><%' # Each of these characters, marks the - # end of an operand token + TOKEN_ENDERS = ',;}) +-*/^&=><%:' # Each of these characters, marks the + + # end of an operand token def __init__(self, formula): self.formula = formula self.items = [] self.token_stack = [] # Used to keep track of arrays, functions, and - # parentheses + # parentheses self.offset = 0 # How many chars have we read self.token = [] # Used to build up token values char by char self._parse() @@ -69,7 +69,7 @@ class Tokenizer(object): ('#', self._parse_error), (' ', self._parse_whitespace), ('\n', self._parse_whitespace), - ('+-*/^&=><%', self._parse_operator), + ('+-*/^&=><%:', self._parse_operator), ('{(', self._parse_opener), (')}', self._parse_closer), (';,', self._parse_separator), @@ -193,10 +193,10 @@ class Tokenizer(object): )) return 2 curr_char = self.formula[self.offset] # guaranteed to be 1 char - assert curr_char in '%*/^&=><+-' + assert curr_char in '%*/^&=><+-:' if curr_char == '%': token = Token('%', Token.OP_POST) - elif curr_char in "*/^&=><": + elif curr_char in "*/^&=><:": token = Token(curr_char, Token.OP_IN) # From here on, curr_char is guaranteed to be in '+-' elif not self.items: @@ -205,9 +205,9 @@ class Tokenizer(object): prev = next((i for i in reversed(self.items) if i.type != Token.WSPACE), None) is_infix = prev and ( - prev.subtype == Token.CLOSE - or prev.type == Token.OP_POST - or prev.type == Token.OPERAND + prev.subtype == Token.CLOSE + or prev.type == Token.OP_POST + or prev.type == Token.OPERAND ) if is_infix: token = Token(curr_char, Token.OP_IN) @@ -312,11 +312,28 @@ class Tokenizer(object): "Unexpected character at position %d in '%s'" % (self.offset, self.formula)) + def merge_constant_range(self): + """Since we treat : as infix operator for the case of OFFSET(C1, 1,1):E10 and alike + for backward compatibility(treating D2:E10 as a single range) the constant range needs + to be merged in to a single object. + """ + if len(self.items) >= 3: + left, op, right = self.items[-3:] + # Ex: A1:3, A1:A3 + is_constant_range = left.subtype == Token.RANGE and \ + op.type == Token.OP_IN and op.value == ':' and \ + (right.subtype == Token.RANGE or right.subtype == Token.NUMBER) + if is_constant_range: + del self.items[-3:] + const_range = left.value + op.value + right.value + self.items.append(Token.make_operand(const_range)) + def save_token(self): """If there's a token being parsed, add it to the item list.""" if self.token: self.items.append(Token.make_operand("".join(self.token))) del self.token[:] + self.merge_constant_range() def render(self): """Convert the parsed tokens back to a string.""" @@ -328,7 +345,6 @@ class Tokenizer(object): class Token(object): - """ A token in an Excel formula. @@ -392,7 +408,6 @@ class Token(object): subtype = cls.RANGE return cls(value, cls.OPERAND, subtype) - # Subexpresssions # # There are 3 types of `Subexpressions`: functions, array literals, and -- GitLab From 82b4875f4d8a7cb44b2c79c2a66c4de55e0e0db8 Mon Sep 17 00:00:00 2001 From: Piti Ongmongkolkul Date: Sun, 15 Sep 2019 01:39:45 +0700 Subject: [PATCH 4/4] merge constant row/column range(A:B, 4:5) --- openpyxl/formula/tests/test_tokenizer.py | 17 ++++++++++++++++- openpyxl/formula/tokenizer.py | 4 ++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/openpyxl/formula/tests/test_tokenizer.py b/openpyxl/formula/tests/test_tokenizer.py index 4dac35b3..9dbdc0ea 100644 --- a/openpyxl/formula/tests/test_tokenizer.py +++ b/openpyxl/formula/tests/test_tokenizer.py @@ -258,7 +258,22 @@ class TestTokenizer(object): (':', OP_IN, ''), ('C3', 'OPERAND', 'RANGE'), (')', 'FUNC', 'CLOSE') - ]) + ]), + ('=SUM(1:1)', + [('SUM(', 'FUNC', 'OPEN'), + ('1:1', 'OPERAND', 'RANGE'), + (')', 'FUNC', 'CLOSE')] + ), + ('=SUM(A1:2)', + [('SUM(', 'FUNC', 'OPEN'), + ('A1:2', 'OPERAND', 'RANGE'), + (')', 'FUNC', 'CLOSE')] + ), + ('=SUM(A:A)', + [('SUM(', 'FUNC', 'OPEN'), + ('A:A', 'OPERAND', 'RANGE'), + (')', 'FUNC', 'CLOSE')] + ) ]) def test_parse(self, tokenizer, formula, tokens): tok = tokenizer.Tokenizer(formula) diff --git a/openpyxl/formula/tokenizer.py b/openpyxl/formula/tokenizer.py index 640f0755..a8b9e4c9 100644 --- a/openpyxl/formula/tokenizer.py +++ b/openpyxl/formula/tokenizer.py @@ -319,8 +319,8 @@ class Tokenizer(object): """ if len(self.items) >= 3: left, op, right = self.items[-3:] - # Ex: A1:3, A1:A3 - is_constant_range = left.subtype == Token.RANGE and \ + # Ex: A1:3, A1:A3, 2:2 A:A + is_constant_range = (left.subtype == Token.RANGE or left.subtype == Token.NUMBER) and \ op.type == Token.OP_IN and op.value == ':' and \ (right.subtype == Token.RANGE or right.subtype == Token.NUMBER) if is_constant_range: -- GitLab