Repository: tox-dev/pipdeptree (python)

Issue:
Error in `canonicalize_name` with `None` metadata name
### What pipdeptree version are you using?

2.21.0

### Are you running pipdeptree in a virtual environment?

Yes

### Describe the problem

Install `docker-py` (https://github.com/docker/docker-py) and `pipdeptree`. 
```shell
pip install docker pipdeptree
```

Then, try to display `docker` requirements: 

```shell
pipdeptree  -p 'docker' --python 'auto'
Traceback (most recent call last):
  File "/home/francis/dev/miniconda/bin/pipdeptree", line 8, in <module>
    sys.exit(main())
             ^^^^^^
  File "/home/francis/dev/miniconda/lib/python3.11/site-packages/pipdeptree/__main__.py", line 31, in main
    pkgs = get_installed_distributions(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/francis/dev/miniconda/lib/python3.11/site-packages/pipdeptree/_discovery.py", line 58, in get_installed_distributions
    normalized_name = canonicalize_name(dist.metadata["Name"])
                      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/francis/dev/miniconda/lib/python3.11/site-packages/packaging/utils.py", line 47, in canonicalize_name
    value = _canonicalize_regex.sub("-", name).lower()
            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: expected string or bytes-like object, got 'NoneType'
```

Although the package is installed and identified properly by `pip`: 
```shell
pip show docker
Name: docker
Version: 6.1.3
Summary: A Python library for the Docker Engine API.
Home-page: https://github.com/docker/docker-py
Author: 
Author-email: 
License: Apache License 2.0
Location: /home/francis/dev/miniconda/envs/weaver-py310/lib/python3.10/site-packages
Requires: packaging, requests, urllib3, websocket-client
Required-by: weaver
```



Pull request description:
Warn about and ignore invalid package metadata in the environment
Resolves #370.

I also decided to break away the "filtering" logic we have in `get_installed_distributions()` into its own function.

Patch:
diff --git a/src/pipdeptree/_discovery.py b/src/pipdeptree/_discovery.py
index 5b42034..5507b61 100644
--- a/src/pipdeptree/_discovery.py
+++ b/src/pipdeptree/_discovery.py
@@ -6,7 +6,7 @@ import subprocess  # noqa: S404
 import sys
 from importlib.metadata import Distribution, distributions
 from pathlib import Path
-from typing import Tuple
+from typing import Iterable, Tuple
 
 from packaging.utils import canonicalize_name
 
@@ -44,7 +44,10 @@ def get_installed_distributions(
     if user_only:
         paths = [p for p in paths if p.startswith(site.getusersitepackages())]
 
-    original_dists = distributions(path=paths)
+    return filter_valid_distributions(distributions(path=paths))
+
+
+def filter_valid_distributions(iterable_dists: Iterable[Distribution]) -> list[Distribution]:
     warning_printer = get_warning_printer()
 
     # Since importlib.metadata.distributions() can return duplicate packages, we need to handle this. pip's approach is
@@ -53,8 +56,17 @@ def get_installed_distributions(
     # See https://github.com/pypa/pip/blob/7c49d06ea4be4635561f16a524e3842817d1169a/src/pip/_internal/metadata/importlib/_envs.py#L34
     seen_dists: dict[str, Distribution] = {}
     first_seen_to_already_seen_dists_dict: dict[Distribution, list[Distribution]] = {}
+
+    # We also need to handle invalid metadata, though we can't get paths to invalid distribution metadata directly since
+    # importlib doesn't expose an API for it. We do have the directory they reside in, so let's use that.
+    site_dir_with_invalid_metadata: set[str] = set()
+
     dists = []
-    for dist in original_dists:
+    for dist in iterable_dists:
+        if not has_valid_metadata(dist):
+            site_dir = str(dist.locate_file(""))
+            site_dir_with_invalid_metadata.add(site_dir)
+            continue
         normalized_name = canonicalize_name(dist.metadata["Name"])
         if normalized_name not in seen_dists:
             seen_dists[normalized_name] = dist
@@ -64,17 +76,31 @@ def get_installed_distributions(
             already_seen_dists = first_seen_to_already_seen_dists_dict.setdefault(seen_dists[normalized_name], [])
             already_seen_dists.append(dist)
 
-    should_print_warning = warning_printer.should_warn() and first_seen_to_already_seen_dists_dict
-    if should_print_warning:
-        warning_printer.print_multi_line(
-            "Duplicate package metadata found",
-            lambda: render_duplicated_dist_metadata_text(first_seen_to_already_seen_dists_dict),
-            ignore_fail=True,
-        )
+    if warning_printer.should_warn():
+        if site_dir_with_invalid_metadata:
+            warning_printer.print_multi_line(
+                "Missing or invalid metadata found in the following site dirs",
+                lambda: render_invalid_metadata_text(site_dir_with_invalid_metadata),
+            )
+        if first_seen_to_already_seen_dists_dict:
+            warning_printer.print_multi_line(
+                "Duplicate package metadata found",
+                lambda: render_duplicated_dist_metadata_text(first_seen_to_already_seen_dists_dict),
+                ignore_fail=True,
+            )
 
     return dists
 
 
+def has_valid_metadata(dist: Distribution) -> bool:
+    return dist.metadata["Name"] is not None
+
+
+def render_invalid_metadata_text(site_dirs_with_invalid_metadata: set[str]) -> None:
+    for site_dir in site_dirs_with_invalid_metadata:
+        print(site_dir, file=sys.stderr)  # noqa: T201
+
+
 FirstSeenWithDistsPair = Tuple[Distribution, Distribution]
 
 


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
index b04e539..3d902a2 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -76,7 +76,18 @@ def fake_dist(tmp_path: Path) -> Path:
     fake_dist_path = fake_site_pkgs / "bar-2.4.5.dist-info"
     fake_dist_path.mkdir(parents=True)
     fake_metadata = Path(fake_dist_path) / "METADATA"
-    with Path(fake_metadata).open("w", encoding=locale.getpreferredencoding(False)) as f:
+    with fake_metadata.open("w", encoding=locale.getpreferredencoding(False)) as f:
         f.write("Metadata-Version: 2.3\n" "Name: bar\n" "Version: 2.4.5\n")
 
     return fake_dist_path
+
+
+@pytest.fixture()
+def fake_dist_with_invalid_metadata(tmp_path: Path) -> Path:
+    "Similar to `fake_dist()`, but creates an invalid METADATA file."
+    fake_site_pkgs = tmp_path / "site-packages"
+    fake_dist_path = fake_site_pkgs / "bar-2.4.5.dist-info"
+    fake_dist_path.mkdir(parents=True)
+    fake_metadata = Path(fake_dist_path) / "METADATA"
+    fake_metadata.touch()
+    return fake_dist_path
diff --git a/tests/test_discovery.py b/tests/test_discovery.py
index 4273623..8a897e3 100644
--- a/tests/test_discovery.py
+++ b/tests/test_discovery.py
@@ -143,3 +143,22 @@ def test_duplicate_metadata(mocker: MockerFixture, capfd: pytest.CaptureFixture[
         "---------------------------------------\n"
     )
     assert err == expected
+
+
+def test_invalid_metadata(
+    mocker: MockerFixture, capfd: pytest.CaptureFixture[str], fake_dist_with_invalid_metadata: Path
+) -> None:
+    fake_site_dir = str(fake_dist_with_invalid_metadata.parent)
+    mocked_sys_path = [fake_site_dir]
+    mocker.patch("pipdeptree._discovery.sys.path", mocked_sys_path)
+
+    dists = get_installed_distributions()
+
+    assert len(dists) == 0
+    out, err = capfd.readouterr()
+    assert not out
+    assert err == (
+        "Warning!!! Missing or invalid metadata found in the following site dirs:\n"
+        f"{fake_site_dir}\n"
+        "------------------------------------------------------------------------\n"
+    )