Repository: tox-dev/pipdeptree (python)

Issue:
Allow showing additional package metadata alongside name
How difficult would it be to add a --license command line switch and then pipdeptree would output not only the package name and version but also the contents of the LICENSE metadata field?

Pull request description:
Implement --license option
Resolves #79.

An example:
```console
$ pipdeptree -d 0 -a --license
chardet==5.2.0 (GNU Lesser General Public License v2 or later (LGPLv2+))
covdefaults==2.3.0 (MIT License)
coverage==7.4.0 (Apache Software License)
diff-cover==8.0.2 (Apache Software License)
distlib==0.3.8 (Python Software Foundation License)
exceptiongroup==1.2.0 (MIT License)
filelock==3.13.1 (The Unlicense (Unlicense))
iniconfig==2.0.0 (MIT License)
Jinja2==3.1.2 (BSD License)
MarkupSafe==2.1.3 (BSD License)
packaging==23.2 (Apache Software License, BSD License)
pip==23.3.2 (MIT License)
pipdeptree==0.1.dev414+g1cd3453 (MIT License)
platformdirs==4.1.0 (MIT License)
pluggy==1.3.0 (MIT License)
pygments==2.17.2 (BSD License)
pytest==7.4.4 (MIT License)
pytest-cov==4.1.0 (MIT License)
pytest-mock==3.12.0 (MIT License)
setuptools==69.0.3 (MIT License)
tomli==2.0.1 (MIT License)
virtualenv==20.25.0 (MIT License)
wheel==0.42.0 (MIT License)
```
Some notes about the choices made:
- Have --license and --freeze be mutually exclusive (as it doesn't make sense to use it when creating freeze requirements)
- Currently, it is only used by the text render option (both unicode and non-unicode options)

Patch:
diff --git a/README.md b/README.md
index e4da0ef..e83e462 100644
--- a/README.md
+++ b/README.md
@@ -234,6 +234,7 @@ options:
                       warning control: suppress will show warnings but return 0 whether or not they are present; silence will not show warnings at all and always return 0; fail will show warnings and return 1 if any are present (default:
                       suppress)
   -r, --reverse       render the dependency tree in the reverse fashion ie. the sub-dependencies are listed with the list of packages that need them under them (default: False)
+  --license           list the license(s) of a package (text render only) (default: False)
 
 select:
   choose what to render
diff --git a/src/pipdeptree/_cli.py b/src/pipdeptree/_cli.py
index d164b3d..648687c 100644
--- a/src/pipdeptree/_cli.py
+++ b/src/pipdeptree/_cli.py
@@ -26,6 +26,7 @@ class Options(Namespace):
     output_format: str | None
     depth: float
     encoding: str
+    license: bool
 
 
 class _Formatter(ArgumentDefaultsHelpFormatter):
@@ -60,6 +61,12 @@ def build_parser() -> ArgumentParser:
         ),
     )
 
+    parser.add_argument(
+        "--license",
+        action="store_true",
+        help="list the license(s) of a package (text render only)",
+    )
+
     select = parser.add_argument_group(title="select", description="choose what to render")
     select.add_argument("--python", default=sys.executable, help="Python interpreter to inspect")
     select.add_argument(
@@ -142,6 +149,8 @@ def get_options(args: Sequence[str] | None) -> Options:
 
     if parsed_args.exclude and (parsed_args.all or parsed_args.packages):
         return parser.error("cannot use --exclude with --packages or --all")
+    if parsed_args.license and parsed_args.freeze:
+        return parser.error("cannot use --license with --freeze")
 
     return cast(Options, parsed_args)
 
diff --git a/src/pipdeptree/_models/package.py b/src/pipdeptree/_models/package.py
index b07917d..cdacee3 100644
--- a/src/pipdeptree/_models/package.py
+++ b/src/pipdeptree/_models/package.py
@@ -2,7 +2,7 @@ from __future__ import annotations
 
 from abc import ABC, abstractmethod
 from importlib import import_module
-from importlib.metadata import PackageNotFoundError, version
+from importlib.metadata import PackageNotFoundError, metadata, version
 from inspect import ismodule
 from typing import TYPE_CHECKING
 
@@ -94,6 +94,8 @@ class DistPackage(Package):
 
     """
 
+    UNKNOWN_LICENSE_STR = "(Unknown license)"
+
     def __init__(self, obj: DistInfoDistribution, req: ReqPackage | None = None) -> None:
         super().__init__(obj)
         self.req = req
@@ -101,6 +103,26 @@ class DistPackage(Package):
     def requires(self) -> list[Requirement]:
         return self._obj.requires()  # type: ignore[no-untyped-call,no-any-return]
 
+    def licenses(self) -> str:
+        try:
+            dist_metadata = metadata(self.key)
+        except PackageNotFoundError:
+            return self.UNKNOWN_LICENSE_STR
+
+        license_strs: list[str] = []
+        classifiers = dist_metadata.get_all("Classifier", [])
+
+        for classifier in classifiers:
+            line = str(classifier)
+            if line.startswith("License"):
+                license_str = line.split(":: ")[-1]
+                license_strs.append(license_str)
+
+        if len(license_strs) == 0:
+            return self.UNKNOWN_LICENSE_STR
+
+        return f'({", ".join(license_strs)})'
+
     @property
     def version(self) -> str:
         return self._obj.version  # type: ignore[no-any-return]
diff --git a/src/pipdeptree/_render/__init__.py b/src/pipdeptree/_render/__init__.py
index 43bda98..0c4e2da 100644
--- a/src/pipdeptree/_render/__init__.py
+++ b/src/pipdeptree/_render/__init__.py
@@ -30,6 +30,7 @@ def render(options: Options, tree: PackageDAG) -> None:
             encoding=options.encoding_type,
             list_all=options.all,
             frozen=options.freeze,
+            include_license=options.license,
         )
 
 
diff --git a/src/pipdeptree/_render/text.py b/src/pipdeptree/_render/text.py
index 2c4a3c8..15e20d1 100644
--- a/src/pipdeptree/_render/text.py
+++ b/src/pipdeptree/_render/text.py
@@ -3,17 +3,20 @@ from __future__ import annotations
 from itertools import chain
 from typing import TYPE_CHECKING, Any
 
+from pipdeptree._models import DistPackage
+
 if TYPE_CHECKING:
-    from pipdeptree._models import DistPackage, PackageDAG, ReqPackage
+    from pipdeptree._models import PackageDAG, ReqPackage
 
 
-def render_text(
+def render_text(  # noqa: PLR0913
     tree: PackageDAG,
     *,
     max_depth: float,
     encoding: str,
     list_all: bool = True,
     frozen: bool = False,
+    include_license: bool = False,
 ) -> None:
     """
     Print tree as text on console.
@@ -32,9 +35,9 @@ def render_text(
         nodes = [p for p in nodes if p.key not in branch_keys]
 
     if encoding in {"utf-8", "utf-16", "utf-32"}:
-        _render_text_with_unicode(tree, nodes, max_depth, frozen)
+        _render_text_with_unicode(tree, nodes, max_depth, frozen, include_license)
     else:
-        _render_text_without_unicode(tree, nodes, max_depth, frozen)
+        _render_text_without_unicode(tree, nodes, max_depth, frozen, include_license)
 
 
 def _render_text_with_unicode(
@@ -42,7 +45,10 @@ def _render_text_with_unicode(
     nodes: list[DistPackage],
     max_depth: float,
     frozen: bool,  # noqa: FBT001
+    include_license: bool,  # noqa: FBT001
 ) -> None:
+    assert not (frozen and include_license)
+
     use_bullets = not frozen
 
     def aux(  # noqa: PLR0913, PLR0917
@@ -83,6 +89,10 @@ def _render_text_with_unicode(
                 prefix += " " if use_bullets else ""
             next_prefix = prefix
             node_str = prefix + bullet + node_str
+
+        if include_license and isinstance(node, DistPackage):
+            node_str += " " + node.licenses()
+
         result = [node_str]
 
         children = tree.get_children(node.key)
@@ -114,7 +124,10 @@ def _render_text_without_unicode(
     nodes: list[DistPackage],
     max_depth: float,
     frozen: bool,  # noqa: FBT001
+    include_license: bool,  # noqa: FBT001
 ) -> None:
+    assert not (frozen and include_license)
+
     use_bullets = not frozen
 
     def aux(
@@ -129,6 +142,8 @@ def _render_text_without_unicode(
         if parent:
             prefix = " " * indent + ("- " if use_bullets else "")
             node_str = prefix + node_str
+        if include_license and isinstance(node, DistPackage):
+            node_str += " " + node.licenses()
         result = [node_str]
         children = [
             aux(c, node, indent=indent + 2, cur_chain=[*cur_chain, c.project_name], depth=depth + 1)


Test patch:
diff --git a/tests/_models/test_package.py b/tests/_models/test_package.py
index a361148..dafa703 100644
--- a/tests/_models/test_package.py
+++ b/tests/_models/test_package.py
@@ -4,6 +4,8 @@ from importlib.metadata import PackageNotFoundError
 from typing import TYPE_CHECKING, Any
 from unittest.mock import Mock
 
+import pytest
+
 from pipdeptree._models import DistPackage, ReqPackage
 
 if TYPE_CHECKING:
@@ -61,6 +63,52 @@ def test_dist_package_as_dict() -> None:
     assert expected == result
 
 
+@pytest.mark.parametrize(
+    ("mocked_metadata", "expected_output"),
+    [
+        pytest.param(
+            Mock(get_all=lambda *args, **kwargs: []),  # noqa: ARG005
+            DistPackage.UNKNOWN_LICENSE_STR,
+            id="no-license",
+        ),
+        pytest.param(
+            Mock(
+                get_all=lambda *args, **kwargs: [  # noqa: ARG005
+                    "License :: OSI Approved :: GNU General Public License v2 (GPLv2)",
+                    "Operating System :: OS Independent",
+                ]
+            ),
+            "(GNU General Public License v2 (GPLv2))",
+            id="one-license-with-one-non-license",
+        ),
+        pytest.param(
+            Mock(
+                get_all=lambda *args, **kwargs: [  # noqa: ARG005
+                    "License :: OSI Approved :: GNU General Public License v2 (GPLv2)",
+                    "License :: OSI Approved :: Apache Software License",
+                ]
+            ),
+            "(GNU General Public License v2 (GPLv2), Apache Software License)",
+            id="more-than-one-license",
+        ),
+    ],
+)
+def test_dist_package_licenses(mocked_metadata: Mock, expected_output: str, monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr("pipdeptree._models.package.metadata", lambda _: mocked_metadata)
+    dist = DistPackage(Mock(project_name="a"))
+    licenses_str = dist.licenses()
+
+    assert licenses_str == expected_output
+
+
+def test_dist_package_licenses_importlib_cant_find_package(monkeypatch: pytest.MonkeyPatch) -> None:
+    monkeypatch.setattr("pipdeptree._models.package.metadata", Mock(side_effect=PackageNotFoundError()))
+    dist = DistPackage(Mock(project_name="a"))
+    licenses_str = dist.licenses()
+
+    assert licenses_str == DistPackage.UNKNOWN_LICENSE_STR
+
+
 def test_req_package_render_as_root() -> None:
     bar = Mock(key="bar", project_name="bar", version="4.1.0")
     bar_req = Mock(key="bar", project_name="bar", version="4.1.0", specs=[(">=", "4.0")])
diff --git a/tests/render/test_render.py b/tests/render/test_render.py
index acef413..28de3d6 100644
--- a/tests/render/test_render.py
+++ b/tests/render/test_render.py
@@ -37,4 +37,6 @@ def test_grahpviz_routing(mocker: MockerFixture) -> None:
 def test_text_routing(mocker: MockerFixture) -> None:
     render = mocker.patch("pipdeptree._render.render_text")
     main([])
-    render.assert_called_once_with(ANY, encoding="utf-8", frozen=False, list_all=False, max_depth=inf)
+    render.assert_called_once_with(
+        ANY, encoding="utf-8", frozen=False, list_all=False, max_depth=inf, include_license=False
+    )
diff --git a/tests/render/test_text.py b/tests/render/test_text.py
index efdda0c..0c8eaeb 100644
--- a/tests/render/test_text.py
+++ b/tests/render/test_text.py
@@ -5,6 +5,7 @@ from typing import TYPE_CHECKING, Callable, Iterator
 import pytest
 
 from pipdeptree._models import PackageDAG
+from pipdeptree._models.package import DistPackage
 from pipdeptree._render.text import render_text
 
 if TYPE_CHECKING:
@@ -467,3 +468,46 @@ def test_render_text_list_all_and_packages_options_used(
     ]
 
     assert "\n".join(expected_output).strip() == captured.out.strip()
+
+
+@pytest.mark.parametrize(
+    ("encoding", "expected_output"),
+    [
+        (
+            "utf-8",
+            [
+                "a==3.4.0 (TEST)",
+                "└── c [required: ==1.0.0, installed: 1.0.0]",
+                "b==2.3.1 (TEST)",
+                "c==1.0.0 (TEST)",
+            ],
+        ),
+        (
+            "ascii",
+            [
+                "a==3.4.0 (TEST)",
+                "  - c [required: ==1.0.0, installed: 1.0.0]",
+                "b==2.3.1 (TEST)",
+                "c==1.0.0 (TEST)",
+            ],
+        ),
+    ],
+)
+def test_render_text_with_license_info(
+    encoding: str,
+    expected_output: str,
+    mock_pkgs: Callable[[MockGraph], Iterator[Mock]],
+    capsys: pytest.CaptureFixture[str],
+    monkeypatch: pytest.MonkeyPatch,
+) -> None:
+    graph: dict[tuple[str, str], list[tuple[str, list[tuple[str, str]]]]] = {
+        ("a", "3.4.0"): [("c", [("==", "1.0.0")])],
+        ("b", "2.3.1"): [],
+        ("c", "1.0.0"): [],
+    }
+    dag = PackageDAG.from_pkgs(list(mock_pkgs(graph)))
+    monkeypatch.setattr(DistPackage, "licenses", lambda _: "(TEST)")
+
+    render_text(dag, max_depth=float("inf"), encoding=encoding, include_license=True)
+    captured = capsys.readouterr()
+    assert "\n".join(expected_output).strip() == captured.out.strip()
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 30b39a4..a55c90a 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -97,3 +97,12 @@ def test_parser_get_options_exclude_combine_not_supported(args: list[str], capsy
 def test_parser_get_options_exclude_only() -> None:
     parsed_args = get_options(["--exclude", "py"])
     assert parsed_args.exclude == "py"
+
+
+def test_parser_get_options_license_and_freeze_together_not_supported(capsys: pytest.CaptureFixture[str]) -> None:
+    with pytest.raises(SystemExit, match="2"):
+        get_options(["--license", "--freeze"])
+
+    out, err = capsys.readouterr()
+    assert not out
+    assert "cannot use --license with --freeze" in err