Skip to content

Commit

Permalink
Configurable FOR IN ZIP behavior if lengths differ.
Browse files Browse the repository at this point in the history
Three modes configured using `mode` option:

- SHORTEST: Items in logger lists ignored. Same as using `zip(...)`
  in Python. Current default.

- STRICT: Lengths must match. Same as `zip(..., strict=True)`.
  Future default.

- LONGEST: Fill values in shorter lists with `fill` value or
  `None`. Same as `itertools.zip_longest(..., fillvalue=fill)`

Fixes #4682.
  • Loading branch information
pekkaklarck committed Mar 13, 2023
1 parent d56637d commit 0a35348
Show file tree
Hide file tree
Showing 15 changed files with 304 additions and 55 deletions.
13 changes: 8 additions & 5 deletions atest/robot/running/for/for.resource
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,27 @@ Check test and failed loop
Run Keyword Should Be ${type} loop ${loop} 1 FAIL &{config}

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

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

Should be IN ZIP loop
[Arguments] ${loop} ${iterations} ${status}=PASS
Should Be FOR Loop ${loop} ${iterations} ${status} flavor=IN ZIP
[Arguments] ${loop} ${iterations} ${status}=PASS ${mode}=${None} ${fill}=${None}
Should Be FOR Loop ${loop} ${iterations} ${status} IN ZIP mode=${mode} fill=${fill}

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

Should be FOR iteration
[Arguments] ${iteration} &{variables}
Expand Down
46 changes: 43 additions & 3 deletions atest/robot/running/for/for_in_zip.robot
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,9 @@ One variable and two lists
One variable and six lists
${loop} = Check test and get loop ${TEST NAME}
Should be IN ZIP loop ${loop} 3
Should be FOR iteration ${loop.body[0]} \${x}=('a', 'x', '1', '1', 'x', 'a')
Should be FOR iteration ${loop.body[1]} \${x}=('b', 'y', '2', '2', 'y', 'b')
Should be FOR iteration ${loop.body[2]} \${x}=('c', 'z', '3', '3', 'z', 'c')
Should be FOR iteration ${loop.body[0]} \${x}=('a', 'x', 1, 1, 'x', 'a')
Should be FOR iteration ${loop.body[1]} \${x}=('b', 'y', 2, 2, 'y', 'b')
Should be FOR iteration ${loop.body[2]} \${x}=('c', 'z', 3, 3, 'z', 'c')

Other iterables
Check Test Case ${TEST NAME}
Expand All @@ -70,6 +70,46 @@ List variable with iterables can be empty
Should be FOR iteration ${tc.body[1].body[0]} \${x}= \${y}= \${z}=
Check Log Message ${tc.body[2].msgs[0]} Executed!

Strict mode
${tc} = Check Test Case ${TEST NAME}
Should be IN ZIP loop ${tc.body[0]} 3 PASS mode=STRICT
Should be IN ZIP loop ${tc.body[2]} 1 FAIL mode=strict

Strict mode requires items to have length
${tc} = Check Test Case ${TEST NAME}
Should be IN ZIP loop ${tc.body[0]} 1 FAIL mode=STRICT

Shortest mode
${tc} = Check Test Case ${TEST NAME}
Should be IN ZIP loop ${tc.body[0]} 3 PASS mode=SHORTEST fill=ignored
Should be IN ZIP loop ${tc.body[3]} 3 PASS mode=\${{'shortest'}}

Shortest mode supports infinite iterators
${tc} = Check Test Case ${TEST NAME}
Should be IN ZIP loop ${tc.body[0]} 5 PASS mode=SHORTEST

Longest mode
${tc} = Check Test Case ${TEST NAME}
Should be IN ZIP loop ${tc.body[0]} 3 PASS mode=LONGEST
Should be IN ZIP loop ${tc.body[3]} 5 PASS mode=LoNgEsT

Longest mode with custom fill value
${tc} = Check Test Case ${TEST NAME}
Should be IN ZIP loop ${tc.body[0]} 5 PASS mode=longest fill=?
Should be IN ZIP loop ${tc.body[3]} 5 PASS mode=longest fill=\${0}

Invalid mode
${tc} = Check Test Case ${TEST NAME}
Should be IN ZIP loop ${tc.body[0]} 1 FAIL mode=bad

Non-existing variable in mode
${tc} = Check Test Case ${TEST NAME}
Should be IN ZIP loop ${tc.body[0]} 1 FAIL mode=\${bad} fill=\${ignored}

Non-existing variable in fill value
${tc} = Check Test Case ${TEST NAME}
Should be IN ZIP loop ${tc.body[0]} 1 FAIL mode=longest fill=\${bad}

Not iterable value
Check test and failed loop ${TEST NAME} IN ZIP

Expand Down
91 changes: 82 additions & 9 deletions atest/testdata/running/for/for_in_zip.robot
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
@{result}
@{LIST1} a b c
@{LIST2} x y z
@{LIST3} 1 2 3 4 5
@{LIST3} ${1} ${2} ${3} ${4} ${5}

*** Test Cases ***
Two variables and lists
Expand All @@ -12,8 +12,8 @@ Two variables and lists
Should Be True ${result} == ['a:x', 'b:y', 'c:z']

Uneven lists
[Documentation] This will ignore any elements after the shortest
... list ends, just like with Python's zip().
[Documentation] Items in longer lists are ignored.
... This behavior can be configured using `mode` option.
FOR ${x} ${y} IN ZIP ${LIST1} ${LIST3}
@{result} = Create List @{result} ${x}:${y}
END
Expand Down Expand Up @@ -46,9 +46,9 @@ One variable and two lists
Should Be True ${result} == ['a:x', 'b:y', 'c:z']

One variable and six lists
FOR ${x} IN ZIP
... ${LIST1} ${LIST2} ${LIST3} ${LIST3} ${LIST2} ${LIST1}
@{result} = Create List @{result} ${{':'.join($x)}}
FOR ${x} IN ZIP ${LIST1} ${LIST2} ${LIST3}
... ${LIST3} ${LIST2} ${LIST1}
@{result} = Create List @{result} ${{':'.join(str(i) for i in $x)}}
END
Should Be True ${result} == ['a:x:1:1:x:a', 'b:y:2:2:y:b', 'c:z:3:3:z:c']

Expand Down Expand Up @@ -80,15 +80,88 @@ List variable with iterables can be empty
END
Log Executed!

Strict mode
[Documentation] FAIL FOR IN ZIP items should have equal lengths in STRICT mode, but lengths are 3, 3 and 5.
FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=STRICT
@{result} = Create List @{result} ${x}:${y}
END
Should Be True ${result} == ['a:x', 'b:y', 'c:z']
FOR ${x} ${y} ${z} IN ZIP ${LIST1} ${LIST2} ${LIST 3} mode=strict
Fail Not executed
END

Strict mode requires items to have length
[Documentation] FAIL FOR IN ZIP items should have length in STRICT mode, but item 2 does not.
FOR ${x} ${y} IN ZIP ${LIST3} ${{itertools.cycle(['A', 'B'])}} mode=STRICT
Fail Not executed
END

Shortest mode
FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=SHORTEST fill=ignored
@{result} = Create List @{result} ${x}:${y}
END
Should Be True ${result} == ['a:x', 'b:y', 'c:z']
@{result} = Create List
FOR ${x} ${y} IN ZIP ${LIST1} ${LIST3} mode=ignored mode=${{'shortest'}}
@{result} = Create List @{result} ${x}:${y}
END
Should Be True ${result} == ['a:1', 'b:2', 'c:3']

Shortest mode supports infinite iterators
FOR ${x} ${y} IN ZIP ${LIST3} ${{itertools.cycle(['A', 'B'])}} mode=SHORTEST
@{result} = Create List @{result} ${x}:${y}
END
Should Be True ${result} == ['1:A', '2:B', '3:A', '4:B', '5:A']

Longest mode
FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=LONGEST
@{result} = Create List @{result} ${x}:${y}
END
Should Be True ${result} == ['a:x', 'b:y', 'c:z']
@{result} = Create List
FOR ${x} ${y} IN ZIP ${LIST1} ${LIST3} mode=LoNgEsT
@{result} = Create List @{result} ${{($x, $y)}}
END
Should Be True ${result} == [('a', 1), ('b', 2), ('c', 3), (None, 4), (None, 5)]

Longest mode with custom fill value
FOR ${x} ${y} IN ZIP ${LIST1} ${LIST3} mode=longest fill=?
@{result} = Create List @{result} ${{($x, $y)}}
END
Should Be True ${result} == [('a', 1), ('b', 2), ('c', 3), ('?', 4), ('?', 5)]
@{result} = Create List
FOR ${x} ${y} IN ZIP ${LIST1} ${LIST3} fill=ignored fill=${0} mode=longest
@{result} = Create List @{result} ${{($x, $y)}}
END
Should Be True ${result} == [('a', 1), ('b', 2), ('c', 3), (0, 4), (0, 5)]

Invalid mode
[Documentation] FAIL Invalid mode: Mode must be 'STRICT', 'SHORTEST' or 'LONGEST', got 'BAD'.
FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=bad
@{result} = Create List @{result} ${x}:${y}
END

Non-existing variable in mode
[Documentation] FAIL Invalid mode: Variable '\${bad}' not found.
FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=${bad} fill=${ignored}
@{result} = Create List @{result} ${x}:${y}
END

Non-existing variable in fill value
[Documentation] FAIL Invalid fill value: Variable '\${bad}' not found.
FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} mode=longest fill=${bad}
@{result} = Create List @{result} ${x}:${y}
END

Not iterable value
[Documentation] FAIL FOR IN ZIP items must all be list-like, got integer '42'.
[Documentation] FAIL FOR IN ZIP items must be list-like, but item 2 is integer.
FOR ${x} ${y} IN ZIP ${LIST1} ${42}
Fail This test case should die before running this.
END

Strings are not considered iterables
[Documentation] FAIL FOR IN ZIP items must all be list-like, got string 'not list'.
FOR ${x} ${y} IN ZIP ${LIST1} not list
[Documentation] FAIL FOR IN ZIP items must be list-like, but item 3 is string.
FOR ${x} ${y} IN ZIP ${LIST1} ${LIST2} not list
Fail This test case should die before running this.
END

Expand Down
4 changes: 3 additions & 1 deletion doc/schema/robot.xsd
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,9 @@
<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:attribute name="start" type="xs:string" /> <!-- Used if IN ENUMERATE has `start`. -->
<xs:attribute name="mode" type="xs:string" /> <!-- Used if IN ZIP has `mode`. -->
<xs:attribute name="fill" type="xs:string" /> <!-- Used if IN ZIP has `fill`. -->
</xs:complexType>
<xs:simpleType name="ForFlavor">
<xs:restriction base="xs:string">
Expand Down
71 changes: 60 additions & 11 deletions doc/userguide/src/CreatingTestData/ControlStructures.rst
Original file line number Diff line number Diff line change
Expand Up @@ -358,10 +358,9 @@ This may be easiest to show with an example:

As the example above illustrates, `FOR-IN-ZIP` loops require their own custom
separator `IN ZIP` (case-sensitive) between loop variables and values.
Values used with `FOR-IN-ZIP` loops must be lists or list-like objects. Looping
will stop when the shortest list is exhausted.
Values used with `FOR-IN-ZIP` loops must be lists or list-like objects.

Lists to iterate over must always be given either as `scalar variables`_ like
Items to iterate over must always be given either as `scalar variables`_ like
`${items}` or as `list variables`_ like `@{lists}` that yield the actual
iterated lists. The former approach is more common and it was already
demonstrated above. The latter approach works like this:
Expand All @@ -380,15 +379,15 @@ demonstrated above. The latter approach works like this:
END

The number of lists to iterate over is not limited, but it must match
the number of loop variables. Alternatively there can be just one loop
the number of loop variables. Alternatively, there can be just one loop
variable that then becomes a Python tuple getting items from all lists.

.. sourcecode:: robotframework

*** Variables ***
@{ABC} a b c
@{XYZ} x y z
@{NUM} 1 2 3 4 5
@{NUM} 1 2 3

*** Test Cases ***
FOR-IN-ZIP with multiple lists
Expand All @@ -402,13 +401,63 @@ variable that then becomes a Python tuple getting items from all lists.
Log Many ${items}[0] ${items}[1] ${items}[2]
END

If lists have an unequal number of items, the shortest list defines how
many iterations there are and values at the end of longer lists are ignored.
For example, the above examples loop only three times and values `4` and `5`
in the `${NUM}` list are ignored.
Starting from Robot Framework 6.1, it is possible to configure what to do if
lengths of the iterated items differ. By default, the shortest item defines how
many iterations there are and values at the end of longer ones are ignored.
This can be changed by using the `mode` option that has three possible values:

.. note:: Getting lists to iterate over from list variables and using
just one loop variable are new features in Robot Framework 3.2.
- `STRICT`: Items must have equal lengths. If not, execution fails. This is
the same as using `strict=True` with Python's zip__ function.
- `SHORTEST`: Items in longer items are ignored. Infinite iterators are supported
in this mode as long as one of the items is exhausted. This is the default
behavior.
- `LONGEST`: The longest item defines how many iterations there are. Missing
values in shorter items are filled-in with value specified using the `fill`
option or `None` if it is not used. This is the same as using Python's
zip_longest__ function except that it has `fillvalue` argument instead of
`fill`.

All these modes are illustrated by the following examples:

.. sourcecode:: robotframework

*** Variables ***
@{CHARACTERS} a b c d f
@{NUMBERS} 1 2 3

*** Test Cases ***
STRICT mode
[Documentation] This loop fails due to lists lengths being different.
FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=STRICT
Log ${c}: ${n}
END

SHORTEST mode
[Documentation] This loop executes three times.
FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=SHORTEST
Log ${c}: ${n}
END

LONGEST mode
[Documentation] This loop executes five times.
... On last two rounds `${n}` has value `None`.
FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=LONGEST
Log ${c}: ${n}
END

LONGEST mode with custom fill value
[Documentation] This loop executes five times.
... On last two rounds `${n}` has value `0`.
FOR ${c} ${n} IN ZIP ${CHARACTERS} ${NUMBERS} mode=LONGEST fill=0
Log ${c}: ${n}
END

.. note:: The behavior if list lengths differ will change in the future
so that the `STRICT` mode will be the default. If that is not desired,
the `SHORTEST` mode needs to be used explicitly.

__ https://docs.python.org/library/functions.html#zip
__ https://docs.python.org/library/itertools.html#itertools.zip_longest

Dictionary iteration
~~~~~~~~~~~~~~~~~~~~
Expand Down
15 changes: 10 additions & 5 deletions src/robot/model/control.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,16 @@ class For(BodyItem):
type = BodyItem.FOR
body_class = Body
repr_args = ('variables', 'flavor', 'values')
__slots__ = ['variables', 'flavor', 'values', 'start']
__slots__ = ['variables', 'flavor', 'values', 'start', 'mode', 'fill']

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

Expand Down Expand Up @@ -62,8 +64,11 @@ def to_dict(self):
'flavor': self.flavor,
'values': list(self.values),
'body': self.body.to_dicts()}
if self.start is not None:
data['start'] = self.start
for name, value in [('start', self.start),
('mode', self.mode),
('fill', self.fill)]:
if value is not None:
data[name] = value
return data


Expand Down
7 changes: 5 additions & 2 deletions src/robot/output/xmllogger.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,11 @@ def end_if_branch(self, branch):

def start_for(self, for_):
attrs = {'flavor': for_.flavor}
if for_.start is not None:
attrs['start'] = for_.start
for name, value in [('start', for_.start),
('mode', for_.mode),
('fill', for_.fill)]:
if value is not None:
attrs[name] = value
self._writer.start('for', attrs)
for name in for_.variables:
self._writer.element('var', name)
Expand Down
5 changes: 5 additions & 0 deletions src/robot/parsing/lexer/statementlexers.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ def lex(self):
if (separator == 'IN ENUMERATE'
and self.statement[-1].value.startswith('start=')):
self.statement[-1].type = Token.OPTION
elif separator == 'IN ZIP':
for token in reversed(self.statement):
if not token.value.startswith(('mode=', 'fill=')):
break
token.type = Token.OPTION


class IfHeaderLexer(TypeAndArguments):
Expand Down

0 comments on commit 0a35348

Please sign in to comment.