diff --git a/atest/robot/variables/list_variable_items.robot b/atest/robot/variables/list_variable_items.robot index 715fb77695e..ccc90c4468e 100644 --- a/atest/robot/variables/list_variable_items.robot +++ b/atest/robot/variables/list_variable_items.robot @@ -48,7 +48,7 @@ Non-existing variable Non-existing index variable Check Test Case ${TESTNAME} -Non-list variable +Non-subscriptable variable Check Test Case ${TESTNAME} Old syntax with `@` still works but is deprecated diff --git a/atest/robot/variables/nested_item_access.robot b/atest/robot/variables/nested_item_access.robot index ee991e97409..6c20ef62714 100644 --- a/atest/robot/variables/nested_item_access.robot +++ b/atest/robot/variables/nested_item_access.robot @@ -27,7 +27,7 @@ Invalid nested list access Invalid nested dict access Check Test Case ${TESTNAME} -Nested access with non-list/dict +Nested access with non-subscriptable Check Test Case ${TESTNAME} Escape nested diff --git a/atest/testdata/standard_libraries/builtin/run_keyword_with_errors.robot b/atest/testdata/standard_libraries/builtin/run_keyword_with_errors.robot index 8e36e87b515..c3d71262946 100644 --- a/atest/testdata/standard_libraries/builtin/run_keyword_with_errors.robot +++ b/atest/testdata/standard_libraries/builtin/run_keyword_with_errors.robot @@ -194,7 +194,7 @@ Expect Error When Access To List Variable Nonexisting Index Syntax 1 Expect Error When Access To List Variable Nonexisting Index Syntax 2 Run Keyword And Expect Error - ... List '\${list}' has no item in index 2. + ... Sequence '\${list}' has no item in index 2. ... Access To List Variable Nonexisting Index Syntax 2 Expect Error When Access To Dictionary Nonexisting Key Syntax 1 @@ -204,7 +204,7 @@ Expect Error When Access To Dictionary Nonexisting Key Syntax 1 Expect Error When Access To Dictionary Nonexisting Key Syntax 2 Run Keyword And Expect Error - ... Dictionary '\${dict}' has no key 'c'. + ... Subscriptable '\${dict}' has no key 'c'. ... Access To Dictionary Variable Nonexisting Key Syntax 2 Expect Error With Explicit GLOB diff --git a/atest/testdata/variables/dict_variable_items.robot b/atest/testdata/variables/dict_variable_items.robot index 3f460ece1a6..ecc5064108c 100644 --- a/atest/testdata/variables/dict_variable_items.robot +++ b/atest/testdata/variables/dict_variable_items.robot @@ -3,6 +3,7 @@ Library Collections Library XML *** Variables *** +${INT} ${15} &{DICT} A=1 B=2 C=3 ${1}=${2} 3=4 ${NONE}=${NONE} = ${SPACE}=${SPACE} &{SQUARES} [=open ]=close []=both [x[y]=mixed ${A} A @@ -70,23 +71,23 @@ List-like values are not manipulated Should Be Equal ${dict}[tuple] ${tuple} Integer key cannot be accessed as string - [Documentation] FAIL Dictionary '\${DICT}' has no key '1'. + [Documentation] FAIL Subscriptable '\${DICT}' has no key '1'. Log ${DICT}[1] String key cannot be accessed as integer - [Documentation] FAIL Dictionary '\${DICT}' has no key '3'. + [Documentation] FAIL Subscriptable '\${DICT}' has no key '3'. Log ${DICT}[${3}] Invalid key - [Documentation] FAIL Dictionary '\${DICT}' has no key 'nonex'. + [Documentation] FAIL Subscriptable '\${DICT}' has no key 'nonex'. Log ${DICT}[nonex] Invalid key using variable - [Documentation] FAIL Dictionary '\${DICT}' has no key 'xxx'. + [Documentation] FAIL Subscriptable '\${DICT}' has no key 'xxx'. Log ${DICT}[${INVALID}] Non-hashable key - [Documentation] FAIL STARTS: Dictionary '\${DICT}' used with invalid key: + [Documentation] FAIL STARTS: Subscriptable '\${DICT}' used with invalid key: Log ${DICT}[@{DICT}] Non-existing variable @@ -99,9 +100,10 @@ Non-existing index variable Non-dict variable [Documentation] FAIL - ... Variable '\${INVALID}' is string, not list or dictionary, \ - ... and thus accessing item '${nonex}' from it is not possible. - Log ${INVALID}[${nonex}] + ... Variable '\${INT}' is integer, which is not subscriptable, and thus \ + ... accessing item '0' from it is not possible. To use '[0]' as a \ + ... literal value, it needs to be escaped like '\\[0]'. + Log ${INT}[0] Sanity check @{items} = Create List @@ -112,7 +114,7 @@ Sanity check Should Be Equal ${items} A: 1, B: 2, C: 3, 1: 2, 3: 4, None: None, : , ${SPACE}: ${SPACE} Old syntax with `&` still works but is deprecated - [Documentation] FAIL Dictionary '\&{DICT}' has no key 'nonex'. + [Documentation] FAIL Subscriptable '\&{DICT}' has no key 'nonex'. Should Be Equal &{DICT}[A] 1 Should Be Equal &{DICT}[${1}] ${2} Log &{DICT}[nonex] diff --git a/atest/testdata/variables/list_variable_items.robot b/atest/testdata/variables/list_variable_items.robot index ee7d566da1a..d1ccb50a910 100644 --- a/atest/testdata/variables/list_variable_items.robot +++ b/atest/testdata/variables/list_variable_items.robot @@ -1,4 +1,5 @@ *** Variables *** +${INT} ${15} @{LIST} A B C D E F G H I J K @{NUMBERS} 1 2 3 &{MAP} first=0 last=-1 @@ -44,43 +45,64 @@ Slicing with variable ... ${LIST[1:]} Invalid index - [Documentation] FAIL List '\${LIST}' has no item in index 12. + [Documentation] FAIL Sequence '\${LIST}' has no item in index 12. Log ${LIST}[12] Invalid index using variable - [Documentation] FAIL List '\${LIST}' has no item in index 13. + [Documentation] FAIL Sequence '\${LIST}' has no item in index 13. Log ${LIST}[${ONE}${3}] Non-int index - [Documentation] FAIL List '\${LIST}' used with invalid index 'invalid'. + [Documentation] FAIL \ + ... Sequence '\${LIST}' used with invalid index 'invalid'. To use \ + ... '[invalid]' as a literal value, it needs to be escaped like \ + ... '\\[invalid]'. Log ${LIST}[invalid] Non-int index using variable 1 - [Documentation] FAIL List '\${LIST}' used with invalid index 'xxx'. + [Documentation] FAIL \ + ... Sequence '\${LIST}' used with invalid index 'xxx'. To use \ + ... '[xxx]' as a literal value, it needs to be escaped like '\\[xxx]'. Log ${LIST}[${INVALID}] Non-int index using variable 2 - [Documentation] FAIL List '\${LIST}' used with invalid index '1.1'. + [Documentation] FAIL \ + ... Sequence '\${LIST}' used with invalid index '1.1'. To use \ + ... '[1.1]' as a literal value, it needs to be escaped like '\\[1.1]'. Log ${LIST}[${1.1}] Empty index - [Documentation] FAIL List '\${LIST}' used with invalid index ''. + [Documentation] FAIL \ + ... Sequence '\${LIST}' used with invalid index ''. To use \ + ... '[]' as a literal value, it needs to be escaped like '\\[]'. Log ${LIST}[] Invalid slice - [Documentation] FAIL List '\${LIST}' used with invalid index '1:2:3:4'. + [Documentation] FAIL \ + ... Sequence '\${LIST}' used with invalid index '1:2:3:4'. To use \ + ... '[1:2:3:4]' as a literal value, it needs to be escaped like \ + ... '\\[1:2:3:4]'. Log ${LIST}[1:2:3:4] Non-int slice index 1 - [Documentation] FAIL List '\${LIST}' used with invalid index 'ooops:'. + [Documentation] FAIL \ + ... Sequence '\${LIST}' used with invalid index 'ooops:'. To use \ + ... '[ooops:]' as a literal value, it needs to be escaped like \ + ... '\\[ooops:]'. Log ${LIST}[ooops:] Non-int slice index 2 - [Documentation] FAIL List '\${LIST}' used with invalid index '1:ooops'. + [Documentation] FAIL \ + ... Sequence '\${LIST}' used with invalid index '1:ooops'. To use \ + ... '[1:ooops]' as a literal value, it needs to be escaped like \ + ... '\\[1:ooops]'. Log ${LIST}[1:ooops] Non-int slice index 3 - [Documentation] FAIL List '\${LIST}' used with invalid index '1:2:ooops'. + [Documentation] FAIL \ + ... Sequence '\${LIST}' used with invalid index '1:2:ooops'. To use \ + ... '[1:2:ooops]' as a literal value, it needs to be escaped like \ + ... '\\[1:2:ooops]'. Log ${LIST}[1:2:ooops] Non-existing variable @@ -91,21 +113,24 @@ Non-existing index variable [Documentation] FAIL Variable '\${nonex index}' not found. Log ${LIST}[${nonex index}] -Non-list variable +Non-subscriptable variable [Documentation] FAIL - ... Variable '\${INVALID}' is string, not list or dictionary, \ - ... and thus accessing item '0' from it is not possible. - Log ${INVALID}[0] + ... Variable '\${INT}' is integer, which is not subscriptable, and thus \ + ... accessing item '0' from it is not possible. To use '[0]' as a \ + ... literal value, it needs to be escaped like '\\[0]'. + Log ${INT}[0] Old syntax with `@` still works but is deprecated [Documentation] `\${list}[1]` and `\@{list}[1]` work same way still. ... In the future latter is deprecated and changed. - ... FAIL List '\@{LIST}' has no item in index 99. + ... FAIL Sequence '\@{LIST}' has no item in index 99. Should Be Equal @{LIST}[0] A Should Be Equal @{LIST}[${-1}] K Log @{LIST}[99] Old syntax with `@` doesn't support new slicing syntax [Documentation] Slicing support should be added in RF 3.3 when `@{list}[index]` changes. - ... FAIL List '\@{LIST}' used with invalid index '1:'. + ... FAIL Sequence '\@{LIST}' used with invalid index '1:'. \ + ... To use '[1:]' as a literal value, it needs to be \ + ... escaped like '\\[1:]'. Log @{LIST}[1:] diff --git a/atest/testdata/variables/nested_item_access.robot b/atest/testdata/variables/nested_item_access.robot index f60ff81029a..662659d6bac 100644 --- a/atest/testdata/variables/nested_item_access.robot +++ b/atest/testdata/variables/nested_item_access.robot @@ -6,6 +6,7 @@ Test Template Should Be Equal ${LIST} [['item'], [1, 2, (3, [4]), 5], 'third'] ${DICT} {'key': {'key': 'value'}, 1: {2: 3}, 'x': {'y': {'z': ''}}} ${MIXED} {'x': [(1, {'y': {'z': ['foo', 'bar', {'': [42]}]}})]} +${STRING} Robot42 *** Test Cases *** Nested list access @@ -28,31 +29,39 @@ Nested access with slicing ${LIST}[1:-1][-1][-2:1:-2][0][0] ${3} Non-existing nested list item - [Documentation] FAIL List '\${LIST}[1][2]' has no item in index 666. + [Documentation] FAIL Sequence '\${LIST}[1][2]' has no item in index 666. ${LIST}[1][2][666] whatever Non-existing nested dict item - [Documentation] FAIL Dictionary '\${DICT}[x][y]' has no key 'nonex'. + [Documentation] FAIL Subscriptable '\${DICT}[x][y]' has no key 'nonex'. ${DICT}[x][y][nonex] whatever Invalid nested list access - [Documentation] FAIL List '\${LIST}[1][2]' used with invalid index 'inv'. + [Documentation] FAIL + ... Sequence '\${LIST}[1][2]' used with invalid index 'inv'. To use \ + ... '[inv]' as a literal value, it needs to be escaped like '\\[inv]'. ${LIST}[1][2][inv] whatever Invalid nested dict access - [Documentation] FAIL STARTS: Dictionary '\${DICT}[key]' used with invalid key: + [Documentation] FAIL STARTS: Subscriptable '\${DICT}[key]' used with invalid key: ${DICT}[key][${DICT}] whatever -Nested access with non-list/dict +Invalid nested string access + [Documentation] FAIL Sequence '\${STRING}[1]' used with invalid index 'inv'. + ${LIST}[1][inv] whatever + +Nested access with non-subscriptable [Documentation] FAIL - ... Variable '\${DICT}[key][key]' is string, not list or dictionary, \ - ... and thus accessing item '0' from it is not possible. - ${DICT}[key][key][0] whatever + ... Variable '\${DICT}[\${1}][\${2}]' is integer, which is not \ + ... subscriptable, and thus accessing item '0' from it is not possible. \ + ... To use '[0]' as a literal value, it needs to be escaped like '\\[0]'. + ${DICT}[${1}][${2}][0] whatever Escape nested ${LIST}[-1]\[0] third[0] ${DICT}[key][key]\[key] value[key] ${DICT}[key]\[key][key] {'key': 'value'}[key][key] + ${STRING}[0]\[-1] R[-1] Nested access doesn't support old `@` and `&` syntax @{LIST}[0][0] ['item'][0] diff --git a/doc/userguide/src/CreatingTestData/Variables.rst b/doc/userguide/src/CreatingTestData/Variables.rst index f8bb1fb3538..237db168486 100644 --- a/doc/userguide/src/CreatingTestData/Variables.rst +++ b/doc/userguide/src/CreatingTestData/Variables.rst @@ -284,62 +284,67 @@ are imports, setups and teardowns where dictionaries can be used as arguments. Accessing list and dictionary items ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -It is possible to access items of lists and dictionaries using special -syntax `${var}[item]`. Accessing items is an old feature, but prior to -Robot Framework 3.1 the syntax was `@{var}[item]` with lists and -`&{var}[item]` with dictionaries. The old syntax was deprecated in +It is possible to access items of subscriptable variables, e.g. lists and +dictionaries, using special syntax `${var}[item]`. Accessing items is an old +feature, but prior to Robot Framework 3.1 the syntax was `@{var}[item]` with +lists and `&{var}[item]` with dictionaries. The old syntax was deprecated in Robot Framework 3.2 and will not be supported in the future. -Accessing list items -'''''''''''''''''''' +.. _sequence items: + +Accessing sequence items +'''''''''''''''''''''''' -It is possible to access a certain item of a list variable with the syntax -`${var}[index]`, where `index` is the index of the selected value. Indices -start from zero, negative indices can be used to access items from the end, -and trying to access an item with too large an index causes an error. -Indices are automatically converted to integers, and it is also possible to -use variables as indices. List items accessed in this manner can be used -similarly as scalar variables. +It is possible to access a certain item of a `sequence`__ variable (e.g. list, +string and bytes) with the syntax `${var}[index]`, where `index` is the index of +the selected value. Indices start from zero, negative indices can be used to +access items from the end, and trying to access an item with too large an index +causes an error. Indices are automatically converted to integers, and it is also +possible to use variables as indices. Sequence items accessed in this manner can +be used similarly as scalar variables. .. sourcecode:: robotframework *** Test Cases *** - List variable item + Sequence variable item Login ${USER}[0] ${USER}[1] Title Should Be Welcome ${USER}[0]! Negative index - Log ${LIST}[-1] + Log ${SEQUENCE}[-1] Index defined as variable - Log ${LIST}[${INDEX}] + Log ${SEQUENCE}[${INDEX}] -List item access supports also the `same "slice" functionality as Python`__ +Sequence item access supports also the `same "slice" functionality as Python`__ with syntax like `${var}[1:]`. With this syntax you do not get a single -item but a slice of the original list. Same way as with Python you can +item but a slice of the original sequence. Same way as with Python you can specify the start index, the end index, and the step: .. sourcecode:: robotframework *** Test Cases *** Start index - Keyword ${LIST}[1:] + Keyword ${SEQUENCE}[1:] End index - Keyword ${LIST}[:4] + Keyword ${SEQUENCE}[:4] Start and end - Keyword ${LIST}[2:-1] + Keyword ${SEQUENCE}[2:-1] Step - Keyword ${LIST}[::2] - Keyword ${LIST}[1:-1:10] + Keyword ${SEQUENCE}[::2] + Keyword ${SEQUENCE}[1:-1:10] .. note:: The slice syntax is new in Robot Framework 3.1 and does not work with the old `@{var}[index]` syntax. +__ https://docs.python.org/3/glossary.html#term-sequence __ https://docs.python.org/glossary.html#term-slice +.. _dictionary items: + Accessing individual dictionary items ''''''''''''''''''''''''''''''''''''' @@ -367,10 +372,20 @@ for more details about this syntax. Login ${USER.name} ${USER.password} Title Should Be Welcome ${USER.name}! +Accessing items of a custom object +'''''''''''''''''''''''''''''''''' + +It is possible to access items of an object of a class that implements the +`__getitem__()`__ method. Depending on the implementation by the class, it is +handled the same as accessing either `sequence items`_ or `dictionary items`_ +as explained in the two subsections above. + +__ https://docs.python.org/3/reference/datamodel.html#object.__getitem__ + Nested item access '''''''''''''''''' -Also nested list and dictionary structures can be accessed using the same +Also nested subscriptable variables can be accessed using the same item access syntax like `${var}[item1][item2]`. This is especially useful when working with JSON data often returned by REST services. For example, if a variable `${DATA}` contains `[{'id': 1, 'name': 'Robot'}, diff --git a/src/robot/parsing/lexer/readers.py b/src/robot/parsing/lexer/readers.py index 1df7415dca4..0f88a705f7d 100644 --- a/src/robot/parsing/lexer/readers.py +++ b/src/robot/parsing/lexer/readers.py @@ -21,7 +21,7 @@ from .context import TestCaseFileContext, ResourceFileContext from .lexers import FileLexer from .splitter import Splitter -from .tokens import EOS, Token +from .tokens import EOL, EOS, Token def get_tokens(source, data_only=False): @@ -75,19 +75,27 @@ def get_tokens(self): self._split_trailing_comment_and_empty_lines(s) for s in statements ) - # Setting local variables, including 'type' below, is performance - # optimization to avoid unnecessary lookups and attribute access. + # Setting local variables is performance optimization to avoid + # unnecessary lookups and attribute access. name_type = Token.NAME - separator_or_eol_type = (Token.EOL, Token.SEPARATOR) + separator_type = Token.SEPARATOR + eol_type = Token.EOL for statement in statements: name_seen = False + separator_after_name = None prev_token = None for token in statement: type = token.type # Performance optimization. if type in ignore: continue - if name_seen and type not in separator_or_eol_type: - yield EOS.from_token(prev_token) + if name_seen: + if type == separator_type: + separator_after_name = token + continue + if type != eol_type: + yield EOS.from_token(prev_token) + if separator_after_name: + yield separator_after_name name_seen = False if type == name_type: name_seen = True @@ -142,12 +150,15 @@ def _split_trailing_comment_and_empty_lines(self, statement): def _split_to_lines(self, statement): current = [] - for tok in statement: - current.append(tok) - if tok.type == tok.EOL: + eol = Token.EOL + for token in statement: + current.append(token) + if token.type == eol: yield current current = [] if current: + if current[-1].type != eol: + current.append(EOL.from_token(current[-1])) yield current diff --git a/src/robot/parsing/lexer/tokens.py b/src/robot/parsing/lexer/tokens.py index b2b48a9046a..c308a598a71 100644 --- a/src/robot/parsing/lexer/tokens.py +++ b/src/robot/parsing/lexer/tokens.py @@ -118,6 +118,17 @@ def __repr__(self): self.lineno, self.columnno) +class EOL(Token): + __slots__ = [] + + def __init__(self, value='', lineno=-1, columnno=-1): + Token.__init__(self, Token.EOL, value, lineno, columnno) + + @classmethod + def from_token(cls, token): + return EOL('', token.lineno, token.columnno + len(token.value)) + + class EOS(Token): __slots__ = [] diff --git a/src/robot/utils/__init__.py b/src/robot/utils/__init__.py index c29533810bf..c7d771033ae 100644 --- a/src/robot/utils/__init__.py +++ b/src/robot/utils/__init__.py @@ -67,8 +67,9 @@ parse_time) from .robottypes import (FALSE_STRINGS, Mapping, MutableMapping, TRUE_STRINGS, is_bytes, is_dict_like, is_falsy, is_integer, - is_list_like, is_number, is_string, is_truthy, - is_unicode, type_name, unicode) + is_list_like, is_number, is_sequence, + is_subscriptable, is_string, is_truthy, is_unicode, + type_name, unicode) from .setter import setter, SetterAwareType from .sortable import Sortable from .text import (cut_long_message, format_assign_message, diff --git a/src/robot/utils/robottypes.py b/src/robot/utils/robottypes.py index b389473dd42..9891e7fa795 100644 --- a/src/robot/utils/robottypes.py +++ b/src/robot/utils/robottypes.py @@ -18,14 +18,14 @@ if PY2: from .robottypes2 import (is_bytes, is_dict_like, is_integer, is_list_like, - is_number, is_pathlike, is_string, is_unicode, - type_name, Mapping, MutableMapping) + is_number, is_pathlike, is_sequence, is_string, + is_unicode, type_name, Mapping, MutableMapping) unicode = unicode else: from .robottypes3 import (is_bytes, is_dict_like, is_integer, is_list_like, - is_number, is_pathlike, is_string, is_unicode, - type_name, Mapping, MutableMapping) + is_number, is_pathlike, is_sequence, is_string, + is_unicode, type_name, Mapping, MutableMapping) unicode = str @@ -57,3 +57,7 @@ def is_truthy(item): def is_falsy(item): """Opposite of :func:`is_truthy`.""" return not is_truthy(item) + + +def is_subscriptable(item): + return hasattr(item, '__getitem__') diff --git a/src/robot/utils/robottypes2.py b/src/robot/utils/robottypes2.py index 1ebf37b73f4..903058d34f0 100644 --- a/src/robot/utils/robottypes2.py +++ b/src/robot/utils/robottypes2.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from collections import Mapping, MutableMapping +from collections import Mapping, MutableMapping, Sequence from UserDict import UserDict from UserString import UserString from types import ClassType, NoneType @@ -66,6 +66,10 @@ def is_list_like(item): return True +def is_sequence(item): + return isinstance(item, Sequence) + + def is_dict_like(item): return isinstance(item, (Mapping, UserDict)) diff --git a/src/robot/utils/robottypes3.py b/src/robot/utils/robottypes3.py index d5a6bf97428..431eba4f719 100644 --- a/src/robot/utils/robottypes3.py +++ b/src/robot/utils/robottypes3.py @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from collections.abc import Mapping, MutableMapping +from collections.abc import Mapping, MutableMapping, Sequence from collections import UserString from io import IOBase @@ -63,6 +63,10 @@ def is_list_like(item): return True +def is_sequence(item): + return isinstance(item, Sequence) + + def is_dict_like(item): return isinstance(item, Mapping) diff --git a/src/robot/variables/replacer.py b/src/robot/variables/replacer.py index 5a5ec9d3cfd..bc71994271d 100644 --- a/src/robot/variables/replacer.py +++ b/src/robot/variables/replacer.py @@ -15,8 +15,8 @@ from robot.errors import DataError, VariableError from robot.output import librarylogger as logger -from robot.utils import (escape, is_dict_like, is_list_like, type_name, - unescape, unic) +from robot.utils import (escape, is_list_like, is_sequence, is_subscriptable, + type_name, unescape, unic) from .search import search_variable, VariableMatch @@ -137,46 +137,59 @@ def _get_variable_item(self, match, value): logger.warn("Accessing variable items using '%s' syntax " "is deprecated. Use '$%s' instead." % (var, var[1:])) for item in match.items: - if is_dict_like(value): - value = self._get_dict_variable_item(name, value, item) - elif is_list_like(value): - value = self._get_list_variable_item(name, value, item) + if is_sequence(value): + value = self._get_sequence_variable_item(name, value, item) + elif is_subscriptable(value): + value = self._get_subscriptable_variable_item(name, value, item) else: raise VariableError( - "Variable '%s' is %s, not list or dictionary, and thus " - "accessing item '%s' from it is not possible." - % (name, type_name(value), item) + "Variable '%s' is %s, which is not subscriptable, and " + "thus accessing item '%s' from it is not possible. To use " + "'[%s]' as a literal value, it needs to be escaped like " + "'\\[%s]'." % (name, type_name(value), item, item, item) ) name = '%s[%s]' % (name, item) return value - def _get_list_variable_item(self, name, variable, index): + def _get_sequence_variable_item(self, name, variable, index): index = self.replace_string(index) try: - index = self._parse_list_variable_index(index, name[0] == '$') + index = self._parse_sequence_variable_index(index, name[0] == '$') except ValueError: - raise VariableError("List '%s' used with invalid index '%s'." - % (name, index)) + raise VariableError("Sequence '%s' used with invalid index '%s'. " + "To use '[%s]' as a literal value, it needs " + "to be escaped like '\\[%s]'." + % (name, index, index, index)) try: return variable[index] except IndexError: - raise VariableError("List '%s' has no item in index %d." + raise VariableError("Sequence '%s' has no item in index %d." % (name, index)) - def _parse_list_variable_index(self, index, support_slice=True): + def _parse_sequence_variable_index(self, index, support_slice=True): if ':' not in index: return int(index) if index.count(':') > 2 or not support_slice: raise ValueError return slice(*[int(i) if i else None for i in index.split(':')]) - def _get_dict_variable_item(self, name, variable, key): - key = self.replace_scalar(key) + def _get_subscriptable_variable_item(self, name, variable, key): + if not isinstance(key, (int, slice)): + key = self.replace_scalar(key) try: return variable[key] except KeyError: - raise VariableError("Dictionary '%s' has no key '%s'." + raise VariableError("Subscriptable '%s' has no key '%s'." % (name, key)) except TypeError as err: - raise VariableError("Dictionary '%s' used with invalid key: %s" + if not isinstance(key, (int, slice)): + try: + key = self._parse_sequence_variable_index( + key, name[0] == '$') + except: + pass + else: + return self._get_subscriptable_variable_item( + name, variable, key) + raise VariableError("Subscriptable '%s' used with invalid key: %s" % (name, err)) diff --git a/src/robot/variables/search.py b/src/robot/variables/search.py index bb02fca47a4..953a4a9a76a 100644 --- a/src/robot/variables/search.py +++ b/src/robot/variables/search.py @@ -83,7 +83,7 @@ def __nonzero__(self): def __unicode__(self): if not self: return '' - items = ''.join('[%s]' % i for i in self.item) if self.items else '' + items = ''.join('[%s]' % i for i in self.items) if self.items else '' return '%s{%s}%s' % (self.identifier, self.base, items) diff --git a/utest/parsing/test_lexer.py b/utest/parsing/test_lexer.py index 5e3e9d31747..6ddeeee9c42 100644 --- a/utest/parsing/test_lexer.py +++ b/utest/parsing/test_lexer.py @@ -10,17 +10,107 @@ T = Token - -def assert_tokens(tokens, expected): - assert_equal(len(tokens), len(expected)) +def assert_tokens(source, expected, get_tokens=get_tokens, data_only=False): + tokens = list(get_tokens(source, data_only)) + assert_equal(len(tokens), len(expected), + 'Expected %d tokens:\n%s\n\nGot %d tokens:\n%s' + % (len(expected), expected, len(tokens), tokens), + values=False) for act, exp in zip(tokens, expected): exp = Token(*exp) assert_equal(act.type, exp.type) - assert_equal(act.value, exp.value) + assert_equal(act.value, exp.value, formatter=repr) assert_equal(act.lineno, exp.lineno) assert_equal(act.columnno, exp.columnno) +class TestName(unittest.TestCase): + + def test_name_on_own_row(self): + self._verify('My Name', + [(T.NAME, 'My Name', 2, 1), (T.EOL, '', 2, 8), (T.EOS, '', 2, 8)]) + self._verify('My Name ', + [(T.NAME, 'My Name', 2, 1), (T.EOL, ' ', 2, 8), (T.EOS, '', 2, 12)]) + self._verify('My Name\n Keyword', + [(T.NAME, 'My Name', 2, 1), (T.EOL, '\n', 2, 8), (T.EOS, '', 2, 9), + (T.SEPARATOR, ' ', 3, 1), (T.KEYWORD, 'Keyword', 3, 5), (T.EOL, '', 3, 12), (T.EOS, '', 3, 12)]) + self._verify('My Name \n Keyword', + [(T.NAME, 'My Name', 2, 1), (T.EOL, ' \n', 2, 8), (T.EOS, '', 2, 11), + (T.SEPARATOR, ' ', 3, 1), (T.KEYWORD, 'Keyword', 3, 5), (T.EOL, '', 3, 12), (T.EOS, '', 3, 12)]) + + def test_name_and_keyword_on_same_row(self): + self._verify('Name Keyword', + [(T.NAME, 'Name', 2, 1), (T.EOS, '', 2, 5), (T.SEPARATOR, ' ', 2, 5), + (T.KEYWORD, 'Keyword', 2, 9), (T.EOL, '', 2, 16), (T.EOS, '', 2, 16)]) + self._verify('N K A', + [(T.NAME, 'N', 2, 1), (T.EOS, '', 2, 2), (T.SEPARATOR, ' ', 2, 2), + (T.KEYWORD, 'K', 2, 4), (T.SEPARATOR, ' ', 2, 5), + (T.ARGUMENT, 'A', 2, 7), (T.EOL, '', 2, 8), (T.EOS, '', 2, 8)]) + self._verify('N ${v}= K', + [(T.NAME, 'N', 2, 1), (T.EOS, '', 2, 2), (T.SEPARATOR, ' ', 2, 2), + (T.ASSIGN, '${v}=', 2, 4), (T.SEPARATOR, ' ', 2, 9), + (T.KEYWORD, 'K', 2, 11), (T.EOL, '', 2, 12), (T.EOS, '', 2, 12)]) + + def test_name_and_setting_on_same_row(self): + self._verify('Name [Documentation] The doc.', + [(T.NAME, 'Name', 2, 1), (T.EOS, '', 2, 5), (T.SEPARATOR, ' ', 2, 5), + (T.DOCUMENTATION, '[Documentation]', 2, 9), (T.SEPARATOR, ' ', 2, 24), + (T.ARGUMENT, 'The doc.', 2, 28), (T.EOL, '', 2, 36), (T.EOS, '', 2, 36)]) + + def _verify(self, data, tokens): + assert_tokens('*** Test Cases ***\n' + data, + [(T.TESTCASE_HEADER, '*** Test Cases ***', 1, 1), + (T.EOL, '\n', 1, 19), + (T.EOS, '', 1, 20)] + tokens) + assert_tokens('*** Keywords ***\n' + data, + [(T.KEYWORD_HEADER, '*** Keywords ***', 1, 1), + (T.EOL, '\n', 1, 17), + (T.EOS, '', 1, 18)] + tokens, + get_tokens=get_resource_tokens) + + +class TestNameWithPipes(unittest.TestCase): + + def test_name_on_own_row(self): + self._verify('| My Name', + [(T.SEPARATOR, '| ', 2, 1), (T.NAME, 'My Name', 2, 3), (T.EOL, '', 2, 10), (T.EOS, '', 2, 10)]) + self._verify('| My Name |', + [(T.SEPARATOR, '| ', 2, 1), (T.NAME, 'My Name', 2, 3), (T.SEPARATOR, ' |', 2, 10), (T.EOL, '', 2, 12), (T.EOS, '', 2, 12)]) + self._verify('| My Name | ', + [(T.SEPARATOR, '| ', 2, 1), (T.NAME, 'My Name', 2, 3), (T.SEPARATOR, ' |', 2, 10), (T.EOL, ' ', 2, 12), (T.EOS, '', 2, 13)]) + + def test_name_and_keyword_on_same_row(self): + self._verify('| Name | Keyword', + [(T.SEPARATOR, '| ', 2, 1), (T.NAME, 'Name', 2, 3), (T.EOS, '', 2, 7), + (T.SEPARATOR, ' | ', 2, 7), (T.KEYWORD, 'Keyword', 2, 10), (T.EOL, '', 2, 17), (T.EOS, '', 2, 17)]) + self._verify('| N | K | A |\n', + [(T.SEPARATOR, '| ', 2, 1), (T.NAME, 'N', 2, 3), (T.EOS, '', 2, 4), + (T.SEPARATOR, ' | ', 2, 4), (T.KEYWORD, 'K', 2, 7), (T.SEPARATOR, ' | ', 2, 8), + (T.ARGUMENT, 'A', 2, 11), (T.SEPARATOR, ' |', 2, 12), (T.EOL, '\n', 2, 14), (T.EOS, '', 2, 15)]) + self._verify('| N | ${v} = | K ', + [(T.SEPARATOR, '| ', 2, 1), (T.NAME, 'N', 2, 6), (T.EOS, '', 2, 7), + (T.SEPARATOR, ' | ', 2, 7), (T.ASSIGN, '${v} =', 2, 12), (T.SEPARATOR, ' | ', 2, 18), + (T.KEYWORD, 'K', 2, 27), (T.EOL, ' ', 2, 28), (T.EOS, '', 2, 32)]) + + def test_name_and_setting_on_same_row(self): + self._verify('| Name | [Documentation] | The doc.', + [(T.SEPARATOR, '| ', 2, 1), (T.NAME, 'Name', 2, 3), (T.EOS, '', 2, 7), (T.SEPARATOR, ' | ', 2, 7), + (T.DOCUMENTATION, '[Documentation]', 2, 10), (T.SEPARATOR, ' | ', 2, 25), + (T.ARGUMENT, 'The doc.', 2, 28), (T.EOL, '', 2, 36), (T.EOS, '', 2, 36)]) + + def _verify(self, data, tokens): + assert_tokens('*** Test Cases ***\n' + data, + [(T.TESTCASE_HEADER, '*** Test Cases ***', 1, 1), + (T.EOL, '\n', 1, 19), + (T.EOS, '', 1, 20)] + tokens) + assert_tokens('*** Keywords ***\n' + data, + [(T.KEYWORD_HEADER, '*** Keywords ***', 1, 1), + (T.EOL, '\n', 1, 17), + (T.EOS, '', 1, 18)] + tokens, + get_tokens=get_resource_tokens) + + + class SourceFormatsTestBase(unittest.TestCase): data = None tokens = None @@ -104,9 +194,8 @@ def test_string(self): self._verify(self.data, data_only=True) def _verify(self, source, data_only=False): - tokens = get_tokens(source, data_only) expected = self.data_tokens if data_only else self.tokens - assert_tokens(list(tokens), expected) + assert_tokens(source, expected, data_only=data_only) class TestGetResourceTokensSourceFormats(SourceFormatsTestBase): @@ -133,8 +222,8 @@ class TestGetResourceTokensSourceFormats(SourceFormatsTestBase): (T.EOL, '\n', 4, 16), (T.EOS, '', 4, 17), (T.NAME, 'NOOP', 5, 1), + (T.EOS, '', 5, 5), (T.SEPARATOR, ' ', 5, 5), - (T.EOS, '', 5, 9), (T.KEYWORD, 'No Operation', 5, 9), (T.EOL, '\n', 5, 21), (T.EOS, '', 5, 22) @@ -172,9 +261,9 @@ def test_string(self): self._verify(self.data, data_only=True) def _verify(self, source, data_only=False): - tokens = get_resource_tokens(source, data_only) expected = self.data_tokens if data_only else self.tokens - assert_tokens(list(tokens), expected) + assert_tokens(source, expected, get_tokens=get_resource_tokens, + data_only=data_only) if __name__ == '__main__': diff --git a/utest/variables/test_variables.py b/utest/variables/test_variables.py index 09edf916696..b8bd8b031b8 100644 --- a/utest/variables/test_variables.py +++ b/utest/variables/test_variables.py @@ -19,6 +19,8 @@ class PythonObject: def __init__(self, a, b): self.a = a self.b = b + def __getitem__(self, index): + return (self.a, self.b)[index] def __str__(self): return '(%s, %s)' % (self.a, self.b) __repr__ = __str__ @@ -222,7 +224,7 @@ def test_math_with_internal_vars_does_not_work_if_first_var_is_float(self): assert_raises(VariableError, self.varz.replace_scalar, '${${1.1} * ${2}}') assert_raises(VariableError, self.varz.replace_scalar, '${${1.1}/${2}}') - def test_list_variable_as_scalar(self): + def var_variable_as_scalar(self): self.varz['@{name}'] = exp = ['spam', 'eggs'] assert_equal(self.varz.replace_scalar('${name}'), exp) assert_equal(self.varz.replace_list(['${name}', 42]), [exp, 42]) @@ -267,6 +269,57 @@ def test_ignore_error(self): assert_equal(v.replace_list(['${x}'+item+'${x}', '@{NON}'], ignore_errors=True), ['x' + item + x_at_end, '@{NON}']) + def test_sequence_subscript(self): + sequences = ( + [42, 'my', 'name'], + (42, ['foo', 'bar'], 'name'), + 'abcDEF123#@$', + b'abcDEF123#@$', + bytearray(b'abcDEF123#@$'), + ) + for var in sequences: + self.varz['${var}'] = var + assert_equal(self.varz.replace_scalar('${var}[0]'), var[0]) + assert_equal(self.varz.replace_scalar('${var}[-2]'), var[-2]) + assert_equal(self.varz.replace_scalar('${var}[::2]'), var[::2]) + assert_equal(self.varz.replace_scalar('${var}[1::2]'), var[1::2]) + assert_equal(self.varz.replace_scalar('${var}[1:-3:2]'), var[1:-3:2]) + assert_raises(VariableError, self.varz.replace_scalar, '${var}[0][1]') + + def test_dict_subscript(self): + a_key = (42, b'key') + var = {'foo': 'bar', 42: [4, 2], 'name': b'my-name', a_key: {4: 2}} + self.varz['${a_key}'] = a_key + self.varz['${var}'] = var + assert_equal(self.varz.replace_scalar('${var}[foo][-1]'), var['foo'][-1]) + assert_equal(self.varz.replace_scalar('${var}[${42}][-1]'), var[42][-1]) + assert_equal(self.varz.replace_scalar('${var}[name][:3]'), var['name'][:3]) + assert_equal(self.varz.replace_scalar('${var}[${a_key}][${4}]'), var[a_key][4]) + assert_raises(VariableError, self.varz.replace_scalar, '${var}[1]') + assert_raises(VariableError, self.varz.replace_scalar, '${var}[42:]') + assert_raises(VariableError, self.varz.replace_scalar, '${var}[nonex]') + + def test_custom_class_subscript(self): + # the two class attributes are accessible via indices 0 and 1 + # slicing should be supported here as well + bytes_key = b'my' + var = PythonObject([1, 2, 3, 4, 5], {bytes_key: 'myname'}) + self.varz['${bytes_key}'] = bytes_key + self.varz['${var}'] = var + assert_equal(self.varz.replace_scalar('${var}[${0}][2::2]'), [3, 5]) + assert_equal(self.varz.replace_scalar('${var}[0][2::2]'), [3, 5]) + assert_equal(self.varz.replace_scalar('${var}[1][${bytes_key}][2:]'), 'name') + assert_equal(self.varz.replace_scalar('${var}\\[1]'), str(var) + '[1]') + assert_equal(self.varz.replace_scalar('${var}[:][0][4]'), var[:][0][4]) + assert_equal(self.varz.replace_scalar('${var}[:-2]'), var[:-2]) + assert_equal(self.varz.replace_scalar('${var}[:7:-2]'), var[:7:-2]) + assert_equal(self.varz.replace_scalar('${var}[2::]'), ()) + assert_raises(IndexError, self.varz.replace_scalar, '${var}[${2}]') + assert_raises(VariableError, self.varz.replace_scalar, '${var}[${bytes_key}]') + + def test_non_subscriptable(self): + assert_raises(VariableError, self.varz.replace_scalar, '${1}[1]') + if __name__ == '__main__': unittest.main()