Repository: ishepard/pydriller
Pull request #100 (2020-03-06 12:53:10)

Update LinesCount to measure count, max, and average number of added/…
…removed lines to a file.

Metrics from paper [A Comparative Analysis of the Efficiency of Change Metrics and Static Code Attributes for Defect Prediction](https://dl.acm.org/doi/pdf/10.1145/1368088.1368114).

## LinesCount

Modified:

* ```count``` - Now return the sum added + removed lines for each modified file in the period ```[from_commit, to_commit]```.

Added:

* ```count_added()``` - the total number of lines added to a file in the period ```[from_commit, to_commit]```

* ```max_added()``` -  the maximum number of lines added to a file in the period ```[from_commit, to_commit]```

* ```avg_added()``` -  the average number of lines added to a file in the period ```[from_commit, to_commit]```

* ```count_removed()``` - the total number of lines removed to a file in the period ```[from_commit, to_commit]```

* ```max_removed()``` -  the maximum number of lines removed to a file in the period ```[from_commit, to_commit]```

* ```avg_removed()``` -  the average number of lines removed to a file in the period ```[from_commit, to_commit]```



Patch:
diff --git a/pydriller/metrics/process/lines_count.py b/pydriller/metrics/process/lines_count.py
index 793c898..3ca3124 100644
--- a/pydriller/metrics/process/lines_count.py
+++ b/pydriller/metrics/process/lines_count.py
@@ -2,36 +2,49 @@
 Module that calculates the number of normalized added and deleted lines of a
 file.
 """
+import statistics
 from pydriller import ModificationType
 from pydriller.metrics.process.process_metric import ProcessMetric
 
-
 class LinesCount(ProcessMetric):
     """
     This class is responsible to implement the following metrics:
-    * Added Lines: the number of added lines in the evolution period
-        [from_commit, to_commit]
-    * Deleted Lines: the number of deleted lines in the evolution period
-        [from_commit, to_commit]
+
+    * Changed Lines: the number of added and deleted lines in the evolution
+        period [from_commit, to_commit]
+
+    * Added Lines: the sum over all commits of the lines of code added to a
+        file in the evolution period [from_commit, to_commit]
+
+    * Max Added Lines: the maximum number of lines of code added to a file
+        per commit in the evolution period [from_commit, to_commit]
+
+    * Average Added Lines: the average lines of code added to a file per commit
+        in the evolution period [from_commit, to_commit]
+
+    * Removed Lines: the sum over all commits of the lines of code removed to a
+        file in the evolution period [from_commit, to_commit]
+
+    * Max Removed Lines: the maximum number of lines of code removed to a file
+        per commit in the evolution period [from_commit, to_commit]
+
+    * Average Removed Lines: the average lines of code removed to a file per
+        commit in the evolution period [from_commit, to_commit]
+
     """
 
-    def count(self):
-        """
-        Calculate the number of normalized (by the total number of added and
-        deleted lines) added and deleted lines per each modified file in
-        'to_commit', returning a dictionary:
-        {
-          filepath: {
-            added: int,
-            removed: int
-          }
-        }
+    def __init__(self, path_to_repo: str,
+                 from_commit: str,
+                 to_commit: str):
+        super().__init__(path_to_repo, from_commit, to_commit)
+        self.__initialize()
 
-        :return: dict of total added and deleted lines per modified file
-        """
-        renamed_files = {}
-        files = {}
+    def __initialize(self):
 
+        self.lines_added = dict()
+        self.lines_removed = dict()
+
+        renamed_files = {}
         for commit in self.repo_miner.traverse_commits():
 
             for modified_file in commit.modifications:
@@ -42,9 +55,93 @@ class LinesCount(ProcessMetric):
                 if modified_file.change_type == ModificationType.RENAME:
                     renamed_files[modified_file.old_path] = filepath
 
-                if filepath not in files:
-                    files[filepath] = {'added': 0, 'removed': 0}
-                files[filepath]['added'] += modified_file.added
-                files[filepath]['removed'] += modified_file.removed
+                self.lines_added.setdefault(filepath, []).append(modified_file.added)
+                self.lines_removed.setdefault(filepath, []).append(modified_file.removed)
+
+    def count(self):
+        """
+        Sum over all commits of the lines of code added and removed to a file .
+
+        :return: int lines added + lines removed
+        """
+        count = dict()
+
+        for path, lines in self.lines_added.items():
+            count[path] = count.get(path, 0) + sum(lines)
+
+        for path, lines in self.lines_removed.items():
+            count[path] = count.get(path, 0) + sum(lines)
+
+        return count
+
+    def count_added(self):
+        """
+        Sum over all commits of the lines of code added to a file .
+
+        :return: int lines added
+        """
+        count = dict()
+        for path, lines in self.lines_added.items():
+            count[path] = sum(lines)
+
+        return count
+
+    def max_added(self):
+        """
+        Maximum number of lines of code added to a file for all commits
+
+        :return: int max number of lines added
+        """
+        maximum = dict()
+        for path, lines in self.lines_added.items():
+            maximum[path] = max(lines)
+
+        return maximum
+
+    def avg_added(self):
+        """
+        Average lines of code added to a file per commit
+
+        :return: int avg number of lines rounded off to the nearest integer
+        """
+        avg = dict()
+        for path, lines in self.lines_added.items():
+            avg[path] = round(statistics.mean(lines))
+
+        return avg
+
+    def count_removed(self):
+        """
+        Sum over all commits of the lines of code removed to a file .
+
+        :return: int lines removed
+        """
+        count = dict()
+        for path, lines in self.lines_removed.items():
+            count[path] = sum(lines)
+
+        return count
+
+    def max_removed(self):
+        """
+        Maximum number of lines of code removed in a file for all commits
+
+        :return: int max number of lines removed
+        """
+        maximum = dict()
+        for path, lines in self.lines_removed.items():
+            maximum[path] = max(lines)
+
+        return maximum
+
+    def avg_removed(self):
+        """
+        Average lines of code removed in a file per commit
+
+        :return: int rounded off to the nearest integer
+        """
+        avg = dict()
+        for path, lines in self.lines_removed.items():
+            avg[path] = round(statistics.mean(lines))
 
-        return files
+        return avg


Test patch:
diff --git a/tests/metrics/process/test_lines_added.py b/tests/metrics/process/test_lines_added.py
new file mode 100644
index 0000000..3a28307
--- /dev/null
+++ b/tests/metrics/process/test_lines_added.py
@@ -0,0 +1,24 @@
+import pytest
+from pathlib import Path
+from pydriller.metrics.process.lines_count import LinesCount
+
+TEST_DATA = [
+   ('test-repos/pydriller', '.gitignore', 'ab36bf45859a210b0eae14e17683f31d19eea041', 'ab36bf45859a210b0eae14e17683f31d19eea041', 197, 197, 197),
+   ('test-repos/pydriller', 'domain/modification.py', 'ab36bf45859a210b0eae14e17683f31d19eea041', '71e053f61fc5d31b3e31eccd9c79df27c31279bf', 61, 48, 20)
+]
+
+@pytest.mark.parametrize('path_to_repo, filepath, from_commit, to_commit, expected_count, expected_max, expected_avg', TEST_DATA)
+def test(path_to_repo, filepath, from_commit, to_commit, expected_count, expected_max, expected_avg):
+    metric = LinesCount(path_to_repo=path_to_repo,
+                        from_commit=from_commit,
+                        to_commit=to_commit)
+
+    actual_count = metric.count_added()
+    actual_max = metric.max_added()
+    actual_avg = metric.avg_added()
+
+    filepath = str(Path(filepath))
+
+    assert actual_count[filepath] == expected_count
+    assert actual_max[filepath] == expected_max
+    assert actual_avg[filepath] == expected_avg
diff --git a/tests/metrics/process/test_lines_count.py b/tests/metrics/process/test_lines_count.py
index 4bc848d..8abdf50 100644
--- a/tests/metrics/process/test_lines_count.py
+++ b/tests/metrics/process/test_lines_count.py
@@ -1,23 +1,19 @@
-from pathlib import Path
-
 import pytest
-
+from pathlib import Path
 from pydriller.metrics.process.lines_count import LinesCount
 
 TEST_DATA = [
-   ('test-repos/pydriller', '.gitignore', 'ab36bf45859a210b0eae14e17683f31d19eea041', 'ab36bf45859a210b0eae14e17683f31d19eea041',
-      {'added': 197, 'removed': 0}),
-
-   ('test-repos/pydriller', 'domain/modification.py', 'fdf671856b260aca058e6595a96a7a0fba05454b', 'ab36bf45859a210b0eae14e17683f31d19eea041',
-      {'added': 49, 'removed': 1})
+   ('test-repos/pydriller', '.gitignore', 'ab36bf45859a210b0eae14e17683f31d19eea041', 'ab36bf45859a210b0eae14e17683f31d19eea041', 197),
+   ('test-repos/pydriller', 'domain/modification.py', 'ab36bf45859a210b0eae14e17683f31d19eea041', '71e053f61fc5d31b3e31eccd9c79df27c31279bf', 65)
 ]
 
-@pytest.mark.parametrize('path_to_repo, filepath, from_commit, to_commit, expected', TEST_DATA)
-def test(path_to_repo, filepath, from_commit, to_commit, expected):
+@pytest.mark.parametrize('path_to_repo, filepath, from_commit, to_commit, expected_count', TEST_DATA)
+def test(path_to_repo, filepath, from_commit, to_commit, expected_count):
     metric = LinesCount(path_to_repo=path_to_repo,
                         from_commit=from_commit,
                         to_commit=to_commit)
 
-    count = metric.count()
+    actual_count = metric.count()
     filepath = str(Path(filepath))
-    assert count[filepath] == expected
+
+    assert actual_count[filepath] == expected_count
diff --git a/tests/metrics/process/test_lines_removed.py b/tests/metrics/process/test_lines_removed.py
new file mode 100644
index 0000000..e11cdc1
--- /dev/null
+++ b/tests/metrics/process/test_lines_removed.py
@@ -0,0 +1,24 @@
+import pytest
+from pathlib import Path
+from pydriller.metrics.process.lines_count import LinesCount
+
+TEST_DATA = [
+   ('test-repos/pydriller', '.gitignore', 'ab36bf45859a210b0eae14e17683f31d19eea041', 'ab36bf45859a210b0eae14e17683f31d19eea041', 0, 0, 0),
+   ('test-repos/pydriller', 'domain/modification.py', 'ab36bf45859a210b0eae14e17683f31d19eea041', '71e053f61fc5d31b3e31eccd9c79df27c31279bf', 4, 3, 1)
+]
+
+@pytest.mark.parametrize('path_to_repo, filepath, from_commit, to_commit, expected_count, expected_max, expected_avg', TEST_DATA)
+def test(path_to_repo, filepath, from_commit, to_commit, expected_count, expected_max, expected_avg):
+    metric = LinesCount(path_to_repo=path_to_repo,
+                        from_commit=from_commit,
+                        to_commit=to_commit)
+
+    actual_count = metric.count_removed()
+    actual_max = metric.max_removed()
+    actual_avg = metric.avg_removed()
+
+    filepath = str(Path(filepath))
+
+    assert actual_count[filepath] == expected_count
+    assert actual_max[filepath] == expected_max
+    assert actual_avg[filepath] == expected_avg