Repository: DataDog/guarddog (python)

Issue:
Create heuristic to identify packages that only have a setup.py file

Pull request:
Create new heuristic to identify PyPI packages with a single Python file (closes #160)
closes #160 

Patch:
diff --git a/README.md b/README.md
index 29f59d1..7bb86ce 100644
--- a/README.md
+++ b/README.md
@@ -96,6 +96,7 @@ Metadata heuristics:
 | typosquatting | Identify packages that are named closely to an highly popular package |
 | potentially_compromised_email_domain | Identify when a package maintainer e-mail domain (and therefore package manager account) might have been compromised |
 | repository_integrity_mismatch | Identify packages with a linked GitHub repository where the package has extra unexpected files |
+| single_python_file | Identify packages that have only a single Python file |
 
 
 ### npm
diff --git a/guarddog/analyzer/metadata/pypi/__init__.py b/guarddog/analyzer/metadata/pypi/__init__.py
index 59c0e63..1992c99 100644
--- a/guarddog/analyzer/metadata/pypi/__init__.py
+++ b/guarddog/analyzer/metadata/pypi/__init__.py
@@ -3,6 +3,7 @@ from guarddog.analyzer.metadata.pypi.potentially_compromised_email_domain import
     PypiPotentiallyCompromisedEmailDomainDetector
 from guarddog.analyzer.metadata.pypi.release_zero import PypiReleaseZeroDetector
 from guarddog.analyzer.metadata.pypi.repository_integrity_mismatch import PypiIntegrityMismatchDetector
+from guarddog.analyzer.metadata.pypi.single_python_file import PypiSinglePythonFileDetector
 from guarddog.analyzer.metadata.pypi.typosquatting import PypiTyposquatDetector
 
 PYPI_METADATA_RULES = {}
@@ -12,7 +13,9 @@ classes = [
     PypiReleaseZeroDetector,
     PypiTyposquatDetector,
     PypiPotentiallyCompromisedEmailDomainDetector,
-    PypiIntegrityMismatchDetector
+    PypiIntegrityMismatchDetector,
+    PypiSinglePythonFileDetector
+
 ]
 
 for detectorClass in classes:
diff --git a/guarddog/analyzer/metadata/pypi/single_python_file.py b/guarddog/analyzer/metadata/pypi/single_python_file.py
new file mode 100644
index 0000000..23a1607
--- /dev/null
+++ b/guarddog/analyzer/metadata/pypi/single_python_file.py
@@ -0,0 +1,35 @@
+import os
+from typing import Optional
+
+from guarddog.analyzer.metadata.detector import Detector
+
+THRESHOLD = 1
+
+
+class PypiSinglePythonFileDetector(Detector):
+
+    def __init__(self):
+        super().__init__(
+            name="single_python_file",
+            description="Identify packages that have only a single Python file"
+        )
+
+    def detect(self, package_info, path: Optional[str] = None, name: Optional[str] = None,
+               version: Optional[str] = None) -> tuple[bool, Optional[str]]:
+        if path is None:
+            raise ValueError("path is needed to run heuristic " + self.get_name())
+        matches = self._has_fewer_than_threshold_python_files(path)
+        return matches, f"This package has {THRESHOLD} or fewer Python source files"
+
+    def _has_fewer_than_threshold_python_files(self, path: str) -> bool:
+        # We could use something like the below instead:
+        # matches = len(glob(f"{path}/**/*.py", recursive=True)) <= THRESHOLD
+        # but it would allegedly be slower since it needs to traverse the whole directory
+        num_python_files = 0
+        for root, dirs, files in os.walk(path):
+            for file in files:
+                if file.lower().endswith('.py'):
+                    num_python_files += 1
+                if num_python_files > THRESHOLD:
+                    return False
+        return True


Test patch:
diff --git a/tests/analyzer/metadata/test_single_python_file.py b/tests/analyzer/metadata/test_single_python_file.py
new file mode 100644
index 0000000..dde20e6
--- /dev/null
+++ b/tests/analyzer/metadata/test_single_python_file.py
@@ -0,0 +1,23 @@
+from guarddog.analyzer.metadata.pypi import PypiSinglePythonFileDetector
+from unittest.mock import patch
+
+pypi_detector = PypiSinglePythonFileDetector()
+
+class TestSinglePythonFile:
+    @patch('os.walk')
+    def test_no_python_file(self, mock_walk):
+        mock_walk.return_value = [(".", ["dir"], ["file"])]
+        matches, _ = pypi_detector.detect({}, "/do/not/care")
+        assert matches
+
+    @patch('os.walk')
+    def test_single_python_file(self, mock_walk):
+        mock_walk.return_value = [(".", ["dir"], ["file.py", "file.txt"])]
+        matches, _ = pypi_detector.detect({}, "/do/not/care")
+        assert matches
+
+    @patch('os.walk')
+    def test_many_python_file(self, mock_walk):
+        mock_walk.return_value = [(".", ["dir"], [f"file{i}.py" for i in range(100)])]
+        matches, _ = pypi_detector.detect({}, "/do/not/care")
+        assert not matches