Skip to content

Commit

Permalink
Handle optional FOR IN ENUMERATE start index in parser.
Browse files Browse the repository at this point in the history
Fixes #4684.
  • Loading branch information
pekkaklarck committed Mar 13, 2023
1 parent e8a0542 commit 41db927
Show file tree
Hide file tree
Showing 17 changed files with 110 additions and 57 deletions.
13 changes: 7 additions & 6 deletions atest/robot/running/for/for.resource
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,21 @@ Resource atest_resource.robot
Check test and get loop
[Arguments] ${test name} ${loop index}=0
${tc} = Check Test Case ${test name}
RETURN ${tc.kws}[${loop index}]
RETURN ${tc.body}[${loop index}]

Check test and failed loop
[Arguments] ${test name} ${type}=FOR ${loop index}=0
[Arguments] ${test name} ${type}=FOR ${loop index}=0 &{config}
${loop} = Check test and get loop ${test name} ${loop index}
Length Should Be ${loop.body} 2
Should Be Equal ${loop.body[0].type} ITERATION
Should Be Equal ${loop.body[1].type} MESSAGE
Run Keyword Should Be ${type} loop ${loop} 1 FAIL
Run Keyword Should Be ${type} loop ${loop} 1 FAIL &{config}

Should be FOR loop
[Arguments] ${loop} ${iterations} ${status}=PASS ${flavor}=IN
[Arguments] ${loop} ${iterations} ${status}=PASS ${flavor}=IN ${start}=${None}
Should Be Equal ${loop.type} FOR
Should Be Equal ${loop.flavor} ${flavor}
Should Be Equal ${loop.start} ${start}
Length Should Be ${loop.body.filter(messages=False)} ${iterations}
Should Be Equal ${loop.status} ${status}

Expand All @@ -31,8 +32,8 @@ Should be IN ZIP loop
Should Be FOR Loop ${loop} ${iterations} ${status} flavor=IN ZIP

Should be IN ENUMERATE loop
[Arguments] ${loop} ${iterations} ${status}=PASS
Should Be FOR Loop ${loop} ${iterations} ${status} flavor=IN ENUMERATE
[Arguments] ${loop} ${iterations} ${status}=PASS ${start}=${None}
Should Be FOR Loop ${loop} ${iterations} ${status} IN ENUMERATE ${start}

Should be FOR iteration
[Arguments] ${iteration} &{variables}
Expand Down
2 changes: 1 addition & 1 deletion atest/robot/running/for/for_dict_iteration.robot
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ FOR IN ENUMERATE loop with three variables

FOR IN ENUMERATE loop with start
${loop} = Check test and get loop ${TESTNAME}
Should be IN ENUMERATE loop ${loop} 3
Should be IN ENUMERATE loop ${loop} 3 start=42

FOR IN ENUMERATE loop with more than three variables is invalid
Check test and failed loop ${TESTNAME} IN ENUMERATE
Expand Down
8 changes: 4 additions & 4 deletions atest/robot/running/for/for_in_enumerate.robot
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ Values from list variable

Start
${loop} = Check test and get loop ${TEST NAME}
Should be IN ENUMERATE loop ${loop} 5
Should be IN ENUMERATE loop ${loop} 5 start=1
Should be FOR iteration ${loop.body[0]} \${index}=1 \${item}=1
Should be FOR iteration ${loop.body[1]} \${index}=2 \${item}=2
Should be FOR iteration ${loop.body[2]} \${index}=3 \${item}=3
Expand All @@ -33,10 +33,10 @@ Escape start
Should be IN ENUMERATE loop ${loop} 2

Invalid start
Check test and failed loop ${TEST NAME} IN ENUMERATE
Check test and failed loop ${TEST NAME} IN ENUMERATE start=invalid

Invalid variable in start
Check test and failed loop ${TEST NAME} IN ENUMERATE
Check test and failed loop ${TEST NAME} IN ENUMERATE start=\${invalid}

Index and two items
${loop} = Check test and get loop ${TEST NAME} 1
Expand Down Expand Up @@ -64,4 +64,4 @@ No values
Check test and failed loop ${TEST NAME} IN ENUMERATE

No values with start
Check test and failed loop ${TEST NAME} IN ENUMERATE
Check test and failed loop ${TEST NAME} IN ENUMERATE start=0
1 change: 1 addition & 0 deletions doc/schema/robot.xsd
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@
<xs:element name="status" type="BodyItemStatus" />
</xs:choice>
<xs:attribute name="flavor" type="ForFlavor" />
<xs:attribute name="start" type="xs:string" /> <!-- Used if IN ENUMERATE uses `start=`. -->
</xs:complexType>
<xs:simpleType name="ForFlavor">
<xs:restriction base="xs:string">
Expand Down
11 changes: 8 additions & 3 deletions src/robot/model/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ class For(BodyItem):
type = BodyItem.FOR
body_class = Body
repr_args = ('variables', 'flavor', 'values')
__slots__ = ['variables', 'flavor', 'values']
__slots__ = ['variables', 'flavor', 'values', 'start']

def __init__(self, variables=(), flavor='IN', values=(), parent=None):
def __init__(self, variables=(), flavor='IN', values=(), start=None,
parent=None):
self.variables = variables
self.flavor = flavor
self.values = values
self.start = start
self.parent = parent
self.body = None

Expand All @@ -55,11 +57,14 @@ def __str__(self):
return 'FOR %s %s %s' % (variables, self.flavor, values)

def to_dict(self):
return {'type': self.type,
data = {'type': self.type,
'variables': list(self.variables),
'flavor': self.flavor,
'values': list(self.values),
'body': self.body.to_dicts()}
if self.start is not None:
data['start'] = self.start
return data


@Body.register
Expand Down
5 changes: 4 additions & 1 deletion src/robot/output/xmllogger.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ def end_if_branch(self, branch):
self._writer.end('branch')

def start_for(self, for_):
self._writer.start('for', {'flavor': for_.flavor})
attrs = {'flavor': for_.flavor}
if for_.start is not None:
attrs['start'] = for_.start
self._writer.start('for', attrs)
for name in for_.variables:
self._writer.element('var', name)
for value in for_.values:
Expand Down
9 changes: 6 additions & 3 deletions src/robot/parsing/lexer/statementlexers.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,15 +190,18 @@ def handles(cls, statement: list, ctx: TestOrKeywordContext):

def lex(self):
self.statement[0].type = Token.FOR
separator_seen = False
separator = None
for token in self.statement[1:]:
if separator_seen:
if separator:
token.type = Token.ARGUMENT
elif normalize_whitespace(token.value) in self.separators:
token.type = Token.FOR_SEPARATOR
separator_seen = True
separator = normalize_whitespace(token.value)
else:
token.type = Token.VARIABLE
if (separator == 'IN ENUMERATE'
and self.statement[-1].value.startswith('start=')):
self.statement[-1].type = Token.OPTION


class IfHeaderLexer(TypeAndArguments):
Expand Down
4 changes: 4 additions & 0 deletions src/robot/parsing/model/blocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,10 @@ def values(self):
def flavor(self):
return self.header.flavor

@property
def start(self):
return self.header.start

def validate(self, ctx: 'ValidationContext'):
if self._body_is_empty():
self.errors += ('FOR loop cannot be empty.',)
Expand Down
8 changes: 8 additions & 0 deletions src/robot/parsing/model/statements.py
Original file line number Diff line number Diff line change
Expand Up @@ -819,6 +819,14 @@ def flavor(self):
separator = self.get_token(Token.FOR_SEPARATOR)
return normalize_whitespace(separator.value) if separator else None

@property
def start(self):
if self.flavor == 'IN ENUMERATE':
value = self.get_value(Token.OPTION)
if value:
return value[len('start='):]
return None

def validate(self, ctx: 'ValidationContext'):
if not self.variables:
self._add_error('no loop variables')
Expand Down
13 changes: 8 additions & 5 deletions src/robot/result/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,9 @@ class For(model.For, StatusMixin, DeprecatedAttributesMixin):
iteration_class = ForIteration
__slots__ = ['status', 'starttime', 'endtime', 'doc']

def __init__(self, variables=(), flavor='IN', values=(), status='FAIL',
starttime=None, endtime=None, doc='', parent=None):
super().__init__(variables, flavor, values, parent)
def __init__(self, variables=(), flavor='IN', values=(), start=None,
status='FAIL', starttime=None, endtime=None, doc='', parent=None):
super().__init__(variables, flavor, values, start, parent)
self.status = status
self.starttime = starttime
self.endtime = endtime
Expand All @@ -187,8 +187,11 @@ def body(self, iterations):
@property
@deprecated
def name(self):
return '%s %s [ %s ]' % (' | '.join(self.variables), self.flavor,
' | '.join(self.values))
variables = ' | '.join(self.variables)
values = ' | '.join(self.values)
if self.start is not None:
values += f' | start={self.start}'
return f'{variables} {self.flavor} [ {values} ]'


class WhileIteration(BodyItem, StatusMixin, DeprecatedAttributesMixin):
Expand Down
9 changes: 4 additions & 5 deletions src/robot/result/xmlelementhandlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,8 @@ class ForHandler(ElementHandler):
children = frozenset(('var', 'value', 'iter', 'status', 'doc', 'msg', 'kw'))

def start(self, elem, result):
return result.body.create_for(flavor=elem.get('flavor'))
return result.body.create_for(flavor=elem.get('flavor'),
start=elem.get('start'))


@ElementHandler.register
Expand All @@ -187,10 +188,8 @@ class WhileHandler(ElementHandler):
children = frozenset(('iter', 'status', 'doc', 'msg', 'kw'))

def start(self, elem, result):
return result.body.create_while(
condition=elem.get('condition'),
limit=elem.get('limit')
)
return result.body.create_while(condition=elem.get('condition'),
limit=elem.get('limit'))


@ElementHandler.register
Expand Down
35 changes: 11 additions & 24 deletions src/robot/running/bodyrunner.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ def run(self, data):
error = DataError(data.error, syntax=True)
else:
run = True
result = ForResult(data.variables, data.flavor, data.values)
result = ForResult(data.variables, data.flavor, data.values, data.start)
with StatusReporter(data, result, self._context, run) as status:
if run:
try:
Expand Down Expand Up @@ -261,7 +261,6 @@ def _to_number_with_arithmetic(self, item):

class ForInZipRunner(ForInRunner):
flavor = 'IN ZIP'
_start = 0

def _resolve_dict_values(self, values):
raise DataError('FOR IN ZIP loops do not support iterating over dictionaries.',
Expand All @@ -279,35 +278,23 @@ def _map_values_to_rounds(self, values, per_round):

class ForInEnumerateRunner(ForInRunner):
flavor = 'IN ENUMERATE'
_start = 0

def _is_dict_iteration(self, values):
if values and values[-1].startswith('start='):
values = values[:-1]
return super()._is_dict_iteration(values)

def _resolve_dict_values(self, values):
self._start, values = self._get_start(values)
return ForInRunner._resolve_dict_values(self, values)
def _get_values_for_rounds(self, data):
self._start = self._resolve_start(data.start)
return super()._get_values_for_rounds(data)

def _resolve_values(self, values):
self._start, values = self._get_start(values)
return ForInRunner._resolve_values(self, values)

def _get_start(self, values):
if not values[-1].startswith('start='):
return 0, values
*values, start = values
if not values:
raise DataError('FOR loop has no loop values.', syntax=True)
def _resolve_start(self, start):
if not start or self._context.dry_run:
return 0
try:
start = self._context.variables.replace_string(start[6:])
start = self._context.variables.replace_string(start)
try:
start = int(start)
return int(start)
except ValueError:
raise DataError(f"Start value must be an integer, got '{start}'.")
except DataError as err:
raise DataError(f'Invalid start value: {err}')
return start, values

def _map_dict_values_to_rounds(self, values, per_round):
if per_round > 3:
Expand All @@ -320,7 +307,7 @@ def _map_dict_values_to_rounds(self, values, per_round):

def _map_values_to_rounds(self, values, per_round):
per_round = max(per_round-1, 1)
values = ForInRunner._map_values_to_rounds(self, values, per_round)
values = super()._map_values_to_rounds(values, per_round)
return ([i] + v for i, v in enumerate(values, start=self._start))

def _raise_wrong_variable_count(self, variables, values):
Expand Down
3 changes: 2 additions & 1 deletion src/robot/running/builder/transformers.py
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,8 @@ def __init__(self, parent):
def build(self, node):
error = format_error(self._get_errors(node))
self.model = self.parent.body.create_for(
node.variables, node.flavor, node.values, lineno=node.lineno, error=error
node.variables, node.flavor, node.values, node.start,
lineno=node.lineno, error=error
)
for step in node.body:
self.visit(step)
Expand Down
4 changes: 2 additions & 2 deletions src/robot/running/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,9 @@ class For(model.For):
__slots__ = ['lineno', 'error']
body_class = Body

def __init__(self, variables=(), flavor='IN', values=(), parent=None,
def __init__(self, variables=(), flavor='IN', values=(), start=None, parent=None,
lineno=None, error=None):
super().__init__(variables, flavor, values, parent)
super().__init__(variables, flavor, values, start, parent)
self.lineno = lineno
self.error = error

Expand Down
30 changes: 28 additions & 2 deletions utest/parsing/test_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,11 +242,37 @@ def test_valid(self):
)
get_and_assert_model(data, expected)

def test_enumerate_with_start(self):
data = '''
*** Test Cases ***
Example
FOR ${x} IN ENUMERATE @{stuff} start=1
Log ${x}
END
'''
expected = For(
header=ForHeader([
Token(Token.FOR, 'FOR', 3, 4),
Token(Token.VARIABLE, '${x}', 3, 11),
Token(Token.FOR_SEPARATOR, 'IN ENUMERATE', 3, 19),
Token(Token.ARGUMENT, '@{stuff}', 3, 35),
Token(Token.OPTION, 'start=1', 3, 47),
]),
body=[
KeywordCall([Token(Token.KEYWORD, 'Log', 4, 8),
Token(Token.ARGUMENT, '${x}', 4, 15)])
],
end=End([
Token(Token.END, 'END', 5, 4)
])
)
get_and_assert_model(data, expected)

def test_nested(self):
data = '''
*** Test Cases ***
Example
FOR ${x} IN 1 2
FOR ${x} IN 1 start=has no special meaning here
FOR ${y} IN RANGE ${x}
Log ${y}
END
Expand All @@ -258,7 +284,7 @@ def test_nested(self):
Token(Token.VARIABLE, '${x}', 3, 11),
Token(Token.FOR_SEPARATOR, 'IN', 3, 19),
Token(Token.ARGUMENT, '1', 3, 25),
Token(Token.ARGUMENT, '2', 3, 30),
Token(Token.ARGUMENT, 'start=has no special meaning here', 3, 30),
]),
body=[
For(
Expand Down
9 changes: 9 additions & 0 deletions utest/reporting/test_jsmodelbuilders.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,15 @@ def test_if(self):
)
self._verify_test(test, body=(exp_if, exp_else_if, exp_else))

def test_for(self):
test = TestSuite().tests.create()
test.body.create_for(variables=['${x}'], values=['a', 'b'])
test.body.create_for(['${x}'], 'IN ENUMERATE', ['a', 'b'], start='1')
end = ('', '', '', '', '', '', (0, None, 0), ())
exp_f1 = (3, '${x} IN [ a | b ]', *end)
exp_f2 = (3, '${x} IN ENUMERATE [ a | b | start=1 ]', *end)
self._verify_test(test, body=(exp_f1, exp_f2))

def test_message_directly_under_test(self):
test = TestSuite().tests.create()
test.body.create_message('Hi from test')
Expand Down
3 changes: 3 additions & 0 deletions utest/running/test_run_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,9 @@ def test_for(self):
self._verify(For(['${i}'], 'IN RANGE', ['10'], lineno=2),
type='FOR', variables=['${i}'], flavor='IN RANGE', values=['10'],
body=[], lineno=2)
self._verify(For(['${i}', '${a}'], 'IN ENUMERATE', ['cat', 'dog'], start='1'),
type='FOR', variables=['${i}', '${a}'], flavor='IN ENUMERATE',
values=['cat', 'dog'], body=[], start='1')

def test_while(self):
self._verify(While(), type='WHILE', body=[])
Expand Down

0 comments on commit 41db927

Please sign in to comment.