Repository: raimon49/pip-licenses

Issue:
Expose maintainers
Some packages do not have the `author` field set, but the `maintainer` one (example: https://github.com/opencv/opencv-python/blob/b776e5ce9f164600a1bd05178a058272122bf02a/setup.py#L272). Exposing the maintainer as well would allow users to work with the maintainer value if the author value would be empty otherwise.

Comments:

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2e60480..1c71659 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,13 @@
 ## CHANGELOG
 
+### 4.2.0
+
+* Implement new option `--with-maintainers`
+* Implement new option `--python`
+* Allow version spec in `--ignore-packages` parameters
+* When the `Author` field is `UNKNOWN`, the output is automatically completed from `Author-email`
+* When the `home-page` field is `UNKNOWN`, the output is automatically completed from `Project-URL`
+
 ### 4.1.0
 
 * Support case-insensitive license name matching around `--fail-on` and `--allow-only` parameters
diff --git a/README.md b/README.md
index 7517b50..76855da 100644
--- a/README.md
+++ b/README.md
@@ -30,6 +30,7 @@ Dump the software license list of Python packages installed with pip.
     * [Format options](#format-options)
         * [Option: with\-system](#option-with-system)
         * [Option: with\-authors](#option-with-authors)
+        * [Option: with\-maintainers](#option-with-maintainers)
         * [Option: with\-urls](#option-with-urls)
         * [Option: with\-description](#option-with-description)
         * [Option: with\-license\-file](#option-with-license-file)
@@ -429,6 +430,12 @@ When executed with the `--with-authors` option, output with author of the packag
  pytz    2017.3   MIT      Stuart Bishop
 ```
 
+#### Option: with-maintainers
+
+When executed with the `--with-maintainers` option, output with maintainer of the package.
+
+**Note:** This option is available for users who want information about the maintainer as well as the author. See [#144](https://github.com/raimon49/pip-licenses/issues/144)
+
 #### Option: with-urls
 
 For packages without Metadata, the license is output as `UNKNOWN`. To get more package information, use the `--with-urls` option.
diff --git a/piplicenses.py b/piplicenses.py
index 488c338..90da0b7 100644
--- a/piplicenses.py
+++ b/piplicenses.py
@@ -49,13 +49,14 @@ from prettytable import NONE as RULE_NONE
 from prettytable import PrettyTable
 
 if TYPE_CHECKING:
-    from typing import Iterator, Optional, Sequence
+    from email.message import Message
+    from typing import Callable, Dict, Iterator, Optional, Sequence
 
 
 open = open  # allow monkey patching
 
 __pkgname__ = "pip-licenses"
-__version__ = "4.1.0"
+__version__ = "4.2.0"
 __author__ = "raimon"
 __license__ = "MIT"
 __summary__ = (
@@ -73,6 +74,7 @@ FIELD_NAMES = (
     "NoticeFile",
     "NoticeText",
     "Author",
+    "Maintainer",
     "Description",
     "URL",
 )
@@ -95,11 +97,50 @@ SUMMARY_OUTPUT_FIELDS = (
     "License",
 )
 
-METADATA_KEYS = {
-    "home-page": ["home-page"],
-    "author": ["author", "author-email"],
-    "license": ["license"],
-    "summary": ["summary"],
+
+def extract_homepage(metadata: Message) -> Optional[str]:
+    """Extracts the homepage attribute from the package metadata.
+
+    Not all python packages have defined a home-page attribute.
+    As a fallback, the `Project-URL` metadata can be used.
+    The python core metadata supports multiple (free text) values for
+    the `Project-URL` field that are comma separated.
+
+    Args:
+        metadata: The package metadata to extract the homepage from.
+
+    Returns:
+        The home page if applicable, None otherwise.
+    """
+    homepage = metadata.get("home-page", None)
+    if homepage is not None:
+        return homepage
+
+    candidates: Dict[str, str] = {}
+
+    for entry in metadata.get_all("Project-URL", []):
+        key, value = entry.split(",", 1)
+        candidates[key.strip()] = value.strip()
+
+    for priority_key in ["Homepage", "Source", "Changelog", "Bug Tracker"]:
+        if priority_key in candidates:
+            return candidates[priority_key]
+
+    return None
+
+
+METADATA_KEYS: Dict[str, List[Callable[[Message], Optional[str]]]] = {
+    "home-page": [extract_homepage],
+    "author": [
+        lambda metadata: metadata.get("author"),
+        lambda metadata: metadata.get("author-email"),
+    ],
+    "maintainer": [
+        lambda metadata: metadata.get("maintainer"),
+        lambda metadata: metadata.get("maintainer-email"),
+    ],
+    "license": [lambda metadata: metadata.get("license")],
+    "summary": [lambda metadata: metadata.get("summary")],
 }
 
 # Mapping of FIELD_NAMES to METADATA_KEYS where they differ by more than case
@@ -168,10 +209,12 @@ def get_packages(
             "noticetext": notice_text,
         }
         metadata = pkg.metadata
-        for field_name, field_selectors in METADATA_KEYS.items():
+        for field_name, field_selector_fns in METADATA_KEYS.items():
             value = None
-            for selector in field_selectors:
-                value = metadata.get(selector, None)  # type: ignore[attr-defined] # noqa: E501
+            for field_selector_fn in field_selector_fns:
+                # Type hint of `Distribution.metadata` states `PackageMetadata`
+                # but it's actually of type `email.Message`
+                value = field_selector_fn(metadata)  # type: ignore
                 if value:
                     break
             pkg_info[field_name] = value or LICENSE_UNKNOWN
@@ -542,6 +585,9 @@ def get_output_fields(args: CustomNamespace) -> list[str]:
     if args.with_authors:
         output_fields.append("Author")
 
+    if args.with_maintainers:
+        output_fields.append("Maintainer")
+
     if args.with_urls:
         output_fields.append("URL")
 
@@ -571,6 +617,8 @@ def get_sortby(args: CustomNamespace) -> str:
         return "Name"
     elif args.order == OrderArg.AUTHOR and args.with_authors:
         return "Author"
+    elif args.order == OrderArg.MAINTAINER and args.with_maintainers:
+        return "Maintainer"
     elif args.order == OrderArg.URL and args.with_urls:
         return "URL"
 
@@ -739,6 +787,7 @@ class OrderArg(NoValueEnum):
     LICENSE = L = auto()
     NAME = N = auto()
     AUTHOR = A = auto()
+    MAINTAINER = M = auto()
     URL = U = auto()
 
 
@@ -897,6 +946,12 @@ def create_parser() -> CompatibleArgumentParser:
         default=False,
         help="dump with package authors",
     )
+    format_options.add_argument(
+        "--with-maintainers",
+        action="store_true",
+        default=False,
+        help="dump with package maintainers",
+    )
     format_options.add_argument(
         "-u",
         "--with-urls",


Test patch:
diff --git a/test_piplicenses.py b/test_piplicenses.py
index 426f31a..674cf83 100644
--- a/test_piplicenses.py
+++ b/test_piplicenses.py
@@ -13,6 +13,7 @@ from enum import Enum, auto
 from importlib.metadata import Distribution
 from types import SimpleNamespace
 from typing import TYPE_CHECKING, Any, List
+from unittest.mock import MagicMock
 
 import docutils.frontend
 import docutils.parsers.rst
@@ -39,6 +40,7 @@ from piplicenses import (
     create_parser,
     create_warn_string,
     enum_key_to_value,
+    extract_homepage,
     factory_styled_table_with_args,
     find_license_from_classifier,
     get_output_fields,
@@ -309,6 +311,17 @@ class TestGetLicenses(CommandLineTestCase):
         output_string = create_output_string(args)
         self.assertIn("Author", output_string)
 
+    def test_with_maintainers(self) -> None:
+        with_maintainers_args = ["--with-maintainers"]
+        args = self.parser.parse_args(with_maintainers_args)
+
+        output_fields = get_output_fields(args)
+        self.assertNotEqual(output_fields, list(DEFAULT_OUTPUT_FIELDS))
+        self.assertIn("Maintainer", output_fields)
+
+        output_string = create_output_string(args)
+        self.assertIn("Maintainer", output_string)
+
     def test_with_urls(self) -> None:
         with_urls_args = ["--with-urls"]
         args = self.parser.parse_args(with_urls_args)
@@ -394,17 +407,32 @@ class TestGetLicenses(CommandLineTestCase):
         self.assertIn("best paired with --format=json", warn_string)
 
     def test_ignore_packages(self) -> None:
-        if "PTable" in SYSTEM_PACKAGES:
-            ignore_pkg_name = "PTable"
-        else:
-            ignore_pkg_name = "prettytable"
-        ignore_packages_args = ["--ignore-package=" + ignore_pkg_name]
+        ignore_pkg_name = "prettytable"
+        ignore_packages_args = [
+            "--ignore-package=" + ignore_pkg_name,
+            "--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"
+        ignore_pkg_spec = ignore_pkg_name + ":1.99.99"
+        ignore_packages_args = [
+            "--ignore-package=" + ignore_pkg_spec,
+            "--with-system",
+        ]
+        args = self.parser.parse_args(ignore_packages_args)
+        table = create_licenses_table(args)
+
+        pkg_name_columns = self._create_pkg_name_columns(table)
+        # It is expected that prettytable will include
+        self.assertIn(ignore_pkg_name, pkg_name_columns)
+
     def test_with_packages(self) -> None:
         pkg_name = "py"
         only_packages_args = ["--packages=" + pkg_name]
@@ -415,10 +443,7 @@ class TestGetLicenses(CommandLineTestCase):
         self.assertListEqual([pkg_name], pkg_name_columns)
 
     def test_with_packages_with_system(self) -> None:
-        if "PTable" in SYSTEM_PACKAGES:
-            pkg_name = "PTable"
-        else:
-            pkg_name = "prettytable"
+        pkg_name = "prettytable"
         only_packages_args = ["--packages=" + pkg_name, "--with-system"]
         args = self.parser.parse_args(only_packages_args)
         table = create_licenses_table(args)
@@ -447,6 +472,13 @@ class TestGetLicenses(CommandLineTestCase):
         sortby = get_sortby(args)
         self.assertEqual("Author", sortby)
 
+    def test_order_maintainer(self) -> None:
+        order_maintainer_args = ["--order=maintainer", "--with-maintainers"]
+        args = self.parser.parse_args(order_maintainer_args)
+
+        sortby = get_sortby(args)
+        self.assertEqual("Maintainer", sortby)
+
     def test_order_url(self) -> None:
         order_url_args = ["--order=url", "--with-urls"]
         args = self.parser.parse_args(order_url_args)
@@ -875,3 +907,56 @@ def test_verify_args(
     capture = capsys.readouterr().err
     for arg in ("invalid code", "--filter-code-page"):
         assert arg in capture
+
+
+def test_extract_homepage_home_page_set() -> None:
+    metadata = MagicMock()
+    metadata.get.return_value = "Foobar"
+
+    assert "Foobar" == extract_homepage(metadata=metadata)  # type: ignore
+
+    metadata.get.assert_called_once_with("home-page", None)
+
+
+def test_extract_homepage_project_url_fallback() -> None:
+    metadata = MagicMock()
+    metadata.get.return_value = None
+
+    # `Homepage` is prioritized higher than `Source`
+    metadata.get_all.return_value = [
+        "Source, source",
+        "Homepage, homepage",
+    ]
+
+    assert "homepage" == extract_homepage(metadata=metadata)  # type: ignore
+
+    metadata.get_all.assert_called_once_with("Project-URL", [])
+
+
+def test_extract_homepage_project_url_fallback_multiple_parts() -> None:
+    metadata = MagicMock()
+    metadata.get.return_value = None
+
+    # `Homepage` is prioritized higher than `Source`
+    metadata.get_all.return_value = [
+        "Source, source",
+        "Homepage, homepage, foo, bar",
+    ]
+
+    assert "homepage, foo, bar" == extract_homepage(
+        metadata=metadata  # type: ignore
+    )
+
+    metadata.get_all.assert_called_once_with("Project-URL", [])
+
+
+def test_extract_homepage_empty() -> None:
+    metadata = MagicMock()
+
+    metadata.get.return_value = None
+    metadata.get_all.return_value = []
+
+    assert None is extract_homepage(metadata=metadata)  # type: ignore
+
+    metadata.get.assert_called_once_with("home-page", None)
+    metadata.get_all.assert_called_once_with("Project-URL", [])