Repository: raimon49/pip-licenses

Issue:
Treat `-` and `_` as equivalent in package names
`pip` treats `-` and `_` as equivalent, but `pip-licenses` differentiates between the two when using the `-p` flag. Having `pip-licenses` also treat `-` and `_` as equivalent makes it easier to work with.

In my particular use case, I have a venv that contains packages from a `requirements.txt` and also additional packages that I don't want licenses of. I then passed the packages in the `requirements.txt` to `pip-licenses -p` and was surprised when the license for `typing-extensions` wasn't found. However, passing `typing_extensions` instead to `pip-licenses` works.

`requirements.txt` in this form can be created from, e.g. `pipenv requirements`.

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b68843f..787f72f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 ## CHANGELOG
 
+### 4.3.1
+
+* Fix to treat package names as normalized as in [PEP 503](https://peps.python.org/pep-0503/) with `--packages` and `--ignore-packages` option
+
 ### 4.3.0
 
 * Implement new option `--no-version`
diff --git a/piplicenses.py b/piplicenses.py
index 44e8798..ed028b1 100644
--- a/piplicenses.py
+++ b/piplicenses.py
@@ -56,7 +56,7 @@ if TYPE_CHECKING:
 open = open  # allow monkey patching
 
 __pkgname__ = "pip-licenses"
-__version__ = "4.3.0"
+__version__ = "4.3.1"
 __author__ = "raimon"
 __license__ = "MIT"
 __summary__ = (
@@ -129,6 +129,24 @@ def extract_homepage(metadata: Message) -> Optional[str]:
     return None
 
 
+PATTERN_DELIMITER = re.compile(r"[-_.]+")
+
+
+def normalize_pkg_name(pkg_name: str) -> str:
+    """Return normalized name according to PEP specification
+
+    See here: https://peps.python.org/pep-0503/#normalized-names
+
+    Args:
+        pkg_name: Package name it is extracted from the package metadata
+                  or specified in the CLI
+
+    Returns:
+        normalized packege name
+    """
+    return PATTERN_DELIMITER.sub("-", pkg_name).lower()
+
+
 METADATA_KEYS: Dict[str, List[Callable[[Message], Optional[str]]]] = {
     "home-page": [extract_homepage],
     "author": [
@@ -254,8 +272,10 @@ def get_packages(
         search_paths = get_python_sys_path(args.python)
 
     pkgs = importlib_metadata.distributions(path=search_paths)
-    ignore_pkgs_as_lower = [pkg.lower() for pkg in args.ignore_packages]
-    pkgs_as_lower = [pkg.lower() for pkg in args.packages]
+    ignore_pkgs_as_normalize = [
+        normalize_pkg_name(pkg) for pkg in args.ignore_packages
+    ]
+    pkgs_as_normalize = [normalize_pkg_name(pkg) for pkg in args.packages]
 
     fail_on_licenses = set()
     if args.fail_on:
@@ -266,16 +286,16 @@ def get_packages(
         allow_only_licenses = set(map(str.strip, args.allow_only.split(";")))
 
     for pkg in pkgs:
-        pkg_name = pkg.metadata["name"]
+        pkg_name = normalize_pkg_name(pkg.metadata["name"])
         pkg_name_and_version = pkg_name + ":" + pkg.metadata["version"]
 
         if (
-            pkg_name.lower() in ignore_pkgs_as_lower
-            or pkg_name_and_version.lower() in ignore_pkgs_as_lower
+            pkg_name.lower() in ignore_pkgs_as_normalize
+            or pkg_name_and_version.lower() in ignore_pkgs_as_normalize
         ):
             continue
 
-        if pkgs_as_lower and pkg_name.lower() not in pkgs_as_lower:
+        if pkgs_as_normalize and pkg_name.lower() not in pkgs_as_normalize:
             continue
 
         if not args.with_system and pkg_name in SYSTEM_PACKAGES:


Test patch:
diff --git a/test_piplicenses.py b/test_piplicenses.py
index 0ac2394..f8cba9b 100644
--- a/test_piplicenses.py
+++ b/test_piplicenses.py
@@ -46,6 +46,7 @@ from piplicenses import (
     get_output_fields,
     get_packages,
     get_sortby,
+    normalize_pkg_name,
     output_colored,
     save_if_needs,
     select_license_by_source,
@@ -429,6 +430,18 @@ class TestGetLicenses(CommandLineTestCase):
         pkg_name_columns = self._create_pkg_name_columns(table)
         self.assertNotIn(ignore_pkg_name, pkg_name_columns)
 
+    def test_ignore_normalized_packages(self) -> None:
+        ignore_pkg_name = "pip-licenses"
+        ignore_packages_args = [
+            "--ignore-package=pip_licenses",
+            "--with-system",
+        ]
+        args = self.parser.parse_args(ignore_packages_args)
+        table = create_licenses_table(args)
+
+        pkg_name_columns = self._create_pkg_name_columns(table)
+        self.assertNotIn(ignore_pkg_name, pkg_name_columns)
+
     def test_ignore_packages_and_version(self) -> None:
         # Fictitious version that does not exist
         ignore_pkg_name = "prettytable"
@@ -453,6 +466,18 @@ class TestGetLicenses(CommandLineTestCase):
         pkg_name_columns = self._create_pkg_name_columns(table)
         self.assertListEqual([pkg_name], pkg_name_columns)
 
+    def test_with_normalized_packages(self) -> None:
+        pkg_name = "typing_extensions"
+        only_packages_args = [
+            "--package=typing-extensions",
+            "--with-system",
+        ]
+        args = self.parser.parse_args(only_packages_args)
+        table = create_licenses_table(args)
+
+        pkg_name_columns = self._create_pkg_name_columns(table)
+        self.assertListEqual([pkg_name], pkg_name_columns)
+
     def test_with_packages_with_system(self) -> None:
         pkg_name = "prettytable"
         only_packages_args = ["--packages=" + pkg_name, "--with-system"]
@@ -920,6 +945,14 @@ def test_verify_args(
         assert arg in capture
 
 
+def test_normalize_pkg_name() -> None:
+    expected_normalized_name = "pip-licenses"
+
+    assert normalize_pkg_name("pip_licenses") == expected_normalized_name
+    assert normalize_pkg_name("pip.licenses") == expected_normalized_name
+    assert normalize_pkg_name("Pip-Licenses") == expected_normalized_name
+
+
 def test_extract_homepage_home_page_set() -> None:
     metadata = MagicMock()
     metadata.get.return_value = "Foobar"