Added incremental_coverage.py to calculate incremental_coverage.

Added a file incremental_coverage.py that calculates
incremental coverage. It uses information from files
changed_lines.py and lcov_parser.py.

We are hoping to run this file in KUnit's presubmit script
"kunit.sh" and redirect this output to a file so that
users get information about their incremental coverage.

Signed-off-by: Darya Verzhbinsky <daryaver@google.com>
Signed-off-by: Daniel Latypov <dlatypov@google.com>
Change-Id: Ie8e4069d24ebf5b9af896f874d115a908a4e603b
diff --git a/BUILD.bazel b/BUILD.bazel
index 5de783c..abc1179 100644
--- a/BUILD.bazel
+++ b/BUILD.bazel
@@ -76,3 +76,15 @@
     python_version = "PY3",
     deps = ["lcov_parser/get_changed_lines.py"],
 )
+
+py_library(
+    name = "incrememtal_coverage",
+    srcs = ["lcov_parser/incrememtal_coverage.py"],
+)
+
+py_test(
+    name = "incrememtal_coverage_test",
+    srcs = ["lcov_parser/incrememtal_coverage_test.py"],
+    python_version = "PY3",
+    deps = ["lcov_parser/incrememtal_coverage.py"],
+)
diff --git a/lcov_parser/incremental_coverage.py b/lcov_parser/incremental_coverage.py
new file mode 100644
index 0000000..592a1c7
--- /dev/null
+++ b/lcov_parser/incremental_coverage.py
@@ -0,0 +1,91 @@
+"""This module calculates the incremental coverage of a git commit using
+information from `git diff` and LCOV's ".info" file.
+"""
+from typing import Tuple, Text, List, Dict
+import changed_lines
+import lcov_parser
+import os
+import re
+import sys
+
+Lines = Dict[int, bool]
+
+
+class CurrDirectoryError(Exception):
+  """CurrDirectoryError is raised when the current working directory
+  cannot be found at the beginning of the absolute paths of the files
+  listed in the ".info" file.
+  """
+  pass
+
+
+def coverage(updated_lines: Dict[Text, List[int]],
+                    covered_lines: Dict[Text, Lines],
+                    curr_dir: Text) -> float:
+  """Computes the the proportion of lines covered in updated_lines, ignoring
+  those that are not instrumented (e.g. like comments or macros).
+
+  Args:
+    updated_lines: a dict mapping files to a list of its updated lines
+    covered_lines: a dict mapping files to another dict mapping lines to
+      whether they've been covered or not
+    curr_dir: name of the current directory, to be removed from beginning of
+      absolute file paths in covered_lines
+
+  Raises:
+      CurrDirectoryError: if there is an issue removing the current directory from the
+        beginning of the absolute paths of the files listed in the ".info" file.
+  """
+  num_covered_lines = 0
+  total_lines = 0
+
+  for (file_name, covered) in covered_lines.items():
+
+    # remove the prefix to the absolute path in the files
+    if not file_name.startswith(curr_dir):
+      raise CurrDirectoryError('Error removing "' + curr_dir + \
+                               '" from beginning of "' +  file_name + '".')
+
+    trimmed_filename = file_name[len(curr_dir) + 1:]
+
+    if trimmed_filename in updated_lines:
+
+      for updated_line in updated_lines[trimmed_filename]:
+        if updated_line in covered:
+          total_lines += 1
+
+          if covered[updated_line]:
+            num_covered_lines += 1
+
+  # some files may not have any instrumented code, e.g. header files with
+  # only macros
+  if total_lines == 0:
+    return None
+
+  return float(num_covered_lines) / total_lines
+
+
+def main(lcov_file_name: Text):
+  """Takes in the name of an LCOV ".info" file and calculates the incremental
+  coverage results of the current git commit.
+
+  Raises:
+      GitDiffSyntaxError if there are formatting issues parsing file name in `file_diff`.
+  """
+  _, covered_lines = lcov_parser.parse(open(lcov_file_name, 'r'))
+
+  git_diff = os.popen('git diff HEAD~').read()
+  updated_lines = changed_lines.from_diff(git_diff)
+
+  perc = coverage(updated_lines, covered_lines, os.getcwd())
+
+  if perc == None:
+    print("None")
+    return
+
+  # prints the coverage as a percentage
+  print(round(perc*100, 2))
+
+
+if __name__ == '__main__':
+  main(sys.argv[1])
diff --git a/lcov_parser/incremental_coverage_test.py b/lcov_parser/incremental_coverage_test.py
new file mode 100755
index 0000000..2979d83
--- /dev/null
+++ b/lcov_parser/incremental_coverage_test.py
@@ -0,0 +1,127 @@
+#!/usr/bin/python3
+
+import unittest
+import changed_lines
+import lcov_parser
+import incremental_coverage
+from io import StringIO
+import os
+
+
+class IncrementalCoverageTest(unittest.TestCase):
+
+
+	def test_normal_output(self):
+		updated_lines = {
+			'file1.txt': [2,3],
+			'file2.txt': [4]
+		}
+
+		covered_lines = {
+			'/dir/file1.txt': {2: True, 3: False},
+			'/dir/file2.txt': {4: True}
+		}
+
+		coverage = \
+			incremental_coverage.coverage(updated_lines, covered_lines, '/dir')
+
+		self.assertAlmostEqual(coverage, 2/3)
+
+	def test_no_coverage(self):
+		updated_lines = {
+			'file1.txt': [9],
+			'file2.txt': [9]
+		}
+
+		covered_lines = {
+			'/dir/file1.txt': {9: False},
+			'/dir/file2.txt': {9: False}
+		}
+
+		coverage = \
+			incremental_coverage.coverage(updated_lines, covered_lines, '/dir')
+
+		self.assertAlmostEqual(coverage, 0.0)
+
+	def test_full_coverage(self):
+		updated_lines = {
+			'file1.txt': [13, 23],
+			'file2.txt': [12]
+		}
+
+		covered_lines = {
+			'/dir/file1.txt': {13: True, 23: True},
+			'/dir/file2.txt': {12: True}
+		}
+
+		coverage = \
+			incremental_coverage.coverage(updated_lines, covered_lines, '/dir')
+
+		self.assertAlmostEqual(coverage, 1.0)
+
+	def test_only_comments(self):
+		updated_lines = {
+			'file1.txt': [4],
+			'file2.txt': [3]
+		}
+
+		covered_lines = {
+			'/dir/file1.txt': {2: True, 3: False},
+			'/dir/file2.txt': {4: True}
+		}
+
+		coverage = \
+			incremental_coverage.coverage(updated_lines, covered_lines, '/dir')
+
+		self.assertIsNone(coverage)
+
+	def test_method_integration(self):
+		updated_lines = changed_lines.from_diff(_GIT_DIFF.strip())
+
+		with StringIO(_TEST_FILE_DATA.strip()) as test_file:
+			_, covered_lines = lcov_parser.parse(test_file)
+
+			coverage = \
+				incremental_coverage.coverage(updated_lines, covered_lines, '/dir')
+
+		self.assertAlmostEqual(2/3, coverage)
+
+
+_TEST_FILE_DATA = """
+TN:kunit_presubmit_tests
+SF: /dir/file1.txt
+DA:2,1
+DA:3,0
+DA:5,1
+end_of_record
+SF: /dir/file2.txt
+DA:1,1
+DA:2,0
+DA:4,1
+end_of_record
+"""
+
+_GIT_DIFF = """
+diff --git a/file1.txt b/file1.txt
+index 170f11f..041325e 100755
+--- a/file1.txt
++++ b/file1.txt
+@@ -1,10 +1,10 @@
+ line 1
++line 2
++line 3
+
+diff --git a/file2.txt b/file2.txt
+index 6675c3b..e69b66c 100644
+--- a/file2.txt
++++ b/file2.txt
+@@ -1,25 +1,28 @@
+ line 1
+ line 2
++# line 3
++line 4
+"""
+
+
+if __name__ == '__main__':
+	unittest.main()