Repository: Parquery/pyicontract-lint

Issue:
Snapshot capture function expects at most one argument
The docs show how you can use snapshots to capture multiple arguments and combine them into one set. However when I try the same idea the code runs without error, but the linter throws the error:

```Snapshot capture function expects at most one argument, but got: ['account_id', 'account2_id'] (snapshot-invalid-arg)```

Does this linter error infer that the snapshot function should only ever take one argument? and if so why does the docs say otherwise?

Docs reference: https://icontract.readthedocs.io/en/latest/usage.html#snapshots-a-k-a-old-argument-values

Edit:
In-fact the linter fails on the documentation example 
```
Snapshot capture function expects at most one argument, but got: ['lst_a', 'lst_b'] (snapshot-invalid-arg)
```

Discussion:

Patch:
diff --git a/icontract_lint/__init__.py b/icontract_lint/__init__.py
index 0b9a53d..d4f2312 100644
--- a/icontract_lint/__init__.py
+++ b/icontract_lint/__init__.py
@@ -35,6 +35,7 @@ class ErrorID(enum.Enum):
     SNAPSHOT_INVALID_ARG = "snapshot-invalid-arg"
     SNAPSHOT_WO_CAPTURE = "snapshot-wo-capture"
     SNAPSHOT_WO_POST = "snapshot-wo-post"
+    SNAPSHOT_WO_NAME = "snapshot-wo-name"
     POST_INVALID_ARG = "post-invalid-arg"
     POST_RESULT_NONE = "post-result-none"
     POST_RESULT_CONFLICT = "post-result-conflict"
@@ -275,15 +276,23 @@ class _LintVisitor(_AstroidVisitor):
         """
         # Find the ``capture=...`` node
         capture_node = None  # type: Optional[astroid.node_classes.NodeNG]
+        name_node = None  # type: Optional[astroid.node_classes.NodeNG]
 
-        if node.args and len(node.args) >= 1:
-            capture_node = node.args[0]
+        if node.args:
+            if len(node.args) >= 1:
+                capture_node = node.args[0]
+
+            if len(node.args) >= 2:
+                name_node = node.args[1]
 
-        if capture_node is None and node.keywords:
+        if node.keywords:
             for keyword_node in node.keywords:
                 if keyword_node.arg == "capture":
                     capture_node = keyword_node.value
 
+                if keyword_node.arg == "name":
+                    name_node = keyword_node.value
+
         if capture_node is None:
             self.errors.append(
                 Error(
@@ -305,26 +314,26 @@ class _LintVisitor(_AstroidVisitor):
             "Expected the inferred capture to be either a lambda or a function definition, but got: {}".format(
                 capture)
 
-        capture_args = capture.argnames()
+        capture_arg_set = set(capture.argnames())
 
-        if len(capture_args) > 1:
+        diff = capture_arg_set.difference(func_arg_set)
+
+        if diff:
             self.errors.append(
                 Error(
                     identifier=ErrorID.SNAPSHOT_INVALID_ARG,
-                    description="Snapshot capture function expects at most one argument, but got: {}".format(
-                        capture_args),
+                    description="Snapshot argument(s) are missing in the function signature: {}".format(
+                        ", ".join(sorted(diff))),
                     filename=self._filename,
                     lineno=node.lineno))
-            return
 
-        if len(capture_args) == 1 and capture_args[0] not in func_arg_set:
+        if len(capture_arg_set) > 1 and name_node is None:
             self.errors.append(
                 Error(
-                    identifier=ErrorID.SNAPSHOT_INVALID_ARG,
-                    description="Snapshot argument is missing in the function signature: {}".format(capture_args[0]),
+                    identifier=ErrorID.SNAPSHOT_WO_NAME,
+                    description="Snapshot involves multiple arguments, but its name has not been specified.",
                     filename=self._filename,
                     lineno=node.lineno))
-            return
 
     def _check_func_decorator(self, node: astroid.nodes.Call, decorator: astroid.bases.Instance, func_arg_set: Set[str],
                               func_has_result: bool) -> None:


Test patch:
diff --git a/tests/test_snapshot.py b/tests/test_snapshot.py
index f55eed3..ba3120c 100644
--- a/tests/test_snapshot.py
+++ b/tests/test_snapshot.py
@@ -34,6 +34,29 @@ class TestSnapshot(unittest.TestCase):
                 errors = icontract_lint.check_file(path=pth)
                 self.assertListEqual([], errors)
 
+    def test_multiple_args_are_ok(self):
+        # This is a regression test related to the issue #32.
+        text = textwrap.dedent("""\
+            from typing import List
+            from icontract import ensure, snapshot
+
+            @snapshot(lambda lst, another_lst: lst + another_lst, name="combined")
+            @ensure(lambda OLD: len(OLD.combined) > 0)
+            def some_func(lst: List[int], another_lst: List[int]) -> None:
+                pass
+            """)
+
+        with tempfile.TemporaryDirectory() as tmp:
+            tmp_path = pathlib.Path(tmp)
+
+            pth = tmp_path / "some_module.py"
+            pth.write_text(text)
+
+            with tests.common.sys_path_with(tmp_path):
+                errors = icontract_lint.check_file(path=pth)
+
+                self.assertListEqual([], errors)
+
     def test_invalid_arg(self):
         text = textwrap.dedent("""\
             from typing import List
@@ -63,11 +86,39 @@ class TestSnapshot(unittest.TestCase):
                     self.assertDictEqual(
                         {
                             'identifier': 'snapshot-invalid-arg',
-                            'description': 'Snapshot argument is missing in the function signature: another_lst',
+                            'description': 'Snapshot argument(s) are missing in the function signature: another_lst',
                             'filename': str(pth),
                             'lineno': lineno
                         }, err.as_mapping())
 
+    def test_multiple_args_and_no_name(self) -> None:
+        text = textwrap.dedent("""\
+            from icontract import snapshot, ensure
+
+            @snapshot(lambda lst, another_lst: lst + another_lst)  # No name is specified here.
+            @ensure(lambda OLD, lst: OLD.lst + OLD.another_lst == lst)
+            def some_func(lst: List[int], another_lst: List[int]) -> None:
+                lst.extend(another_lst)
+            """)
+
+        with tempfile.TemporaryDirectory() as tmp:
+            tmp_path = pathlib.Path(tmp)
+
+            pth = tmp_path / "some_module.py"
+            pth.write_text(text)
+
+            with tests.common.sys_path_with(tmp_path):
+                errors = icontract_lint.check_file(path=pth)
+                self.assertEqual(1, len(errors))
+
+                self.assertDictEqual(
+                    {
+                        'identifier': 'snapshot-wo-name',
+                        'description': 'Snapshot involves multiple arguments, but its name has not been specified.',
+                        'filename': str(pth),
+                        'lineno': 3
+                    }, errors[0].as_mapping())  # type: ignore
+
     def test_without_post(self):
         text = textwrap.dedent("""\
             from typing import List