Handle lambdas within multi-line expressions in check failures.

Currently if a lambda occurs in a multi-line expression not on the
last line then it will not be added to the cache of nodes for its
file, so when a check failure includes a frame that is a call to the
lambda, no code (or varmap for the innermost frame) for the lambda
will be present.

Now lambdas will be added to the cache of nodes if their last line
number is different than the containing expression. This means that
lambdas will be treated differently depending on whether they are in
the last line of their top-level expression or not: only the lambda
will be displayed for lambdas not on the last line and the entire
containing expression for lambdas on the last line.

Bug: 944848
Change-Id: I95dc56e00c3152130f64cd91abbc5bdeca4e5bce
Reviewed-on: https://chromium-review.googlesource.com/c/infra/luci/recipes-py/+/1535708
Reviewed-by: Robbie Iannucci <iannucci@chromium.org>
Commit-Queue: Garrett Beaty <gbeaty@chromium.org>
diff --git a/recipe_engine/internal/test/magic_check_fn.py b/recipe_engine/internal/test/magic_check_fn.py
index c39e238..ea07b0a 100644
--- a/recipe_engine/internal/test/magic_check_fn.py
+++ b/recipe_engine/internal/test/magic_check_fn.py
@@ -284,8 +284,24 @@
         # show up in a stack trace), and add it to _PARSED_FILE_CACHE. Note that
         # even though this is a simple statement, it could still span multiple
         # lines.
-        max_line = max(map(lambda n: getattr(n, 'lineno', 0), ast.walk(node)))
+        def get_max_lineno(node):
+          return max(getattr(n, 'lineno', 0) for n in ast.walk(node))
+        max_line = get_max_lineno(node)
+        # If the expression contains any nested lambda definitions, then its
+        # possible we may encounter frames that are executing the lambda. In
+        # that case, any lambdas that do not appear on the last line of the
+        # expression will have frames with line numbers different from frames
+        # that are executing the containing expression, so look for any nested
+        # lambdas and add them to the cache with the appropriate line number.
+        for n in ast.walk(node):
+          if isinstance(n, ast.Lambda):
+            # Adding the lambda to the nodes when its on the last line results
+            # in both the containing expression and the lambda itself appearing
+            # in the failure output, so don't add the lambda to the nodes
+            lambda_max_line = get_max_lineno(n)
+            if lambda_max_line != max_line:
+              self._PARSED_FILE_CACHE[filename][lambda_max_line].append(n)
     return self._PARSED_FILE_CACHE[filename][lineno]
   def _process_frame(self, frame, with_vars):
diff --git a/unittests/checker_test.py b/unittests/checker_test.py
index 3ac32b6..3e689d9 100755
--- a/unittests/checker_test.py
+++ b/unittests/checker_test.py
@@ -172,6 +172,31 @@
       self.mk('<lambda>', "map((lambda v: check((v in targ['a']))), vals)",
               {"targ['a'].keys()": "['sub']", 'v': "'whee'"}))
+  def test_lambda_in_multiline_expr_call(self):
+    c = Checker('<filename>', 0, lambda: None, (), {})
+    def wrap(f):
+      return f
+    def body(check, f):
+      f(check)
+    value = 'food'
+    target = ['foo', 'bar', 'baz']
+    # Make sure the lambda is part of a larger expression that ends on a
+    # later line than the lambda
+    func = [lambda check: check(value == target),
+            lambda check: check(value in target),
+            lambda check: check(value and target),
+           ][1]
+    body(c, func)
+    self.assertEqual(len(c.failed_checks), 1)
+    self.assertEqual(len(c.failed_checks[0].frames), 2)
+    self.assertEqual(
+        self.sanitize(c.failed_checks[0].frames[0]),
+        self.mk('body', 'f(check)', None))
+    self.assertEqual(
+        self.sanitize(c.failed_checks[0].frames[1]),
+        self.mk('<lambda>', '(lambda check: check((value in target)))',
+                {'value': "'food'", 'target': "['foo', 'bar', 'baz']"}))
   def test_if_test(self):
     c = Checker('<filename>', 0, lambda: None, (), {})
     def body(check):