Repository: tox-dev/pipdeptree
Pull request #333 (2024-03-15 09:34:02)

replace pkg_resources with importlib.metadata and packaging

xiacunshun: fix https://github.com/tox-dev/pipdeptree/issues/175
xiacunshun: `Distribution.requires` now returns strings like `['markdown; extra == "filters"', 'markdown; extra == "markdown"']`.
I print them in the tree as the following.
CT3==3.3.1
├── markdown [required: Any, extra: extra == "filters", installed: 3.5.1]
└── markdown [required: Any, extra: extra == "markdown", installed: 3.5.1]
xiacunshun: The result looks corret with my own tests.
I haven't configured out why the CI tests failed. Could you please take a look? @gaborbernat 
kemzeb: Thanks for contributing @xiacunshun! I do see some areas in your PR that aren't related to migrating the codebase to use `importlib.metadata` and I think it would be best to handle these other changes in some other PR(s) and have this one focused on just the issue it hand.
xiacunshun: I am considering that make the default devel to 2 or 3, because if it is no-limitied the tree will be too big to read.
kemzeb: > I am considering that make the default devel to 2 or 3, because if it is no-limitied the tree will be too big to read.

Sorry, I don't understand what you are saying here. I think you are saying your want to break this PR into separate PRs?
xiacunshun: > > I am considering that make the default devel to 2 or 3, because if it is no-limitied the tree will be too big to read.
> 
> Sorry, I don't understand what you are saying here. I think you are saying your want to break this PR into separate PRs?

Oh no, that was just a thought I had just now, it's unrelated to this PR.
kemzeb: Please resolve the merge conflict so the CI can run again.
kemzeb: It looks like your tests are failing because of the mock objects we are creating. Since we no longer are mimicking `DistInfoDistribution`, we may have some fields that we set for the mock objects that we no longer use in the actual code. And when we try to access a field that doesn't exist, `unittest.Mock` will just return another `unittest.Mock` object (when we expected a string type).
xiacunshun: I found normalize name method is implemented already in https://github.com/python/cpython/blob/3.12/Lib/importlib/metadata/__init__.py#L460
, so I think we can use it latter in another PR.
kemzeb: > I found normalize name method is implemented already in https://github.com/python/cpython/blob/3.12/Lib/importlib/metadata/__init__.py#L460 , so I think we can use it latter in another PR.

From quickly skimming older Python versions of importlib.metadata, it looks like that method doesn't exist. Let's no worry about it since we can just do it ourselves.
kemzeb: There are additional areas that I see need touch-ups, but let's focus on getting most of the tests to pass again. Like I said [here](https://github.com/tox-dev/pipdeptree/pull/333#issuecomment-2011179743), it has to deal with the mock objects being passed around during tests. When we access a non-existent property/field/method in a `unittest.Mock` object, it just returns a new mock e.g.
```python
>>> m = mock.Mock()
# id exists in every mock object
>>> m.id
<Mock name='mock.id' id='4296866448'>
# The non-existent field
>>> m.hello_world
# A new mock
<Mock name='mock.hello_world' id='4296865920'>
>>> 
```
xiacunshun: 



> There are additional areas that I see need touch-ups, but let's focus on getting most of the tests to pass again. Like I said [here](https://github.com/tox-dev/pipdeptree/pull/333#issuecomment-2011179743), it has to deal with the mock objects being passed around during tests. When we access a non-existent property/field/method in a `unittest.Mock` object, it just returns a new mock e.g.
> 
> ```python
> >>> m = mock.Mock()
> # id exists in every mock object
> >>> m.id
> <Mock name='mock.id' id='4296866448'>
> # The non-existent field
> >>> m.hello_world
> # A new mock
> <Mock name='mock.hello_world' id='4296865920'>
> >>> 
> ```

The test cases are a bit harder to modify than I anticipated, because the `graph` constructed in the test cases completely mimics the original API. If we want them to pass, we might need to make some more modifications to the code. For instance, since the original `pkgs` is in dict format, it can directly use the `key`. Therefore, we need to make a judgment:
```
-        self.key = pep503_normalize(self._obj.metadata['Name'])
+        if hasattr(obj, "key"):
+            self.key = pep503_normalize(obj.key)
+        else:
+            self.key = pep503_normalize(self._obj.metadata["Name"])
```
xiacunshun: We also need make the attributes which are not supported by `dict` into `property`.
Like
```
+    @property
+    def editable(self) -> bool:
+        return self.direct_url_dict["editable"]
+
+    @property
+    def direct_url(self) -> str:
+        return self.direct_url_dict["url"]
+
+    @property
+    def raw_name(self) -> str:
+        return self.project_name
```
xiacunshun: However, I've encountered another issue that I haven't quite figured out yet. I might need to take another look at it tomorrow.
```
.tox/py311/lib/python3.11/site-packages/pipdeptree/_models/dag.py:45: in from_pkgs
    for r in p.requires():
_ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _

self = <DistPackage("parent-package")>

    def requires(self) -> list[Requirement]:
        req_list = []
        req_name_list = []
        if self._obj.requires:
>           for r in self._obj.requires:
E           TypeError: 'Mock' object is not iterable

.tox/py311/lib/python3.11/site-packages/pipdeptree/_models/package.py:124: TypeError
```
xiacunshun: > However, I've encountered another issue that I haven't quite figured out yet. I might need to take another look at it tomorrow.
> 
> ```
> .tox/py311/lib/python3.11/site-packages/pipdeptree/_models/dag.py:45: in from_pkgs
>     for r in p.requires():
> _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _
> 
> self = <DistPackage("parent-package")>
> 
>     def requires(self) -> list[Requirement]:
>         req_list = []
>         req_name_list = []
>         if self._obj.requires:
> >           for r in self._obj.requires:
> E           TypeError: 'Mock' object is not iterable
> 
> .tox/py311/lib/python3.11/site-packages/pipdeptree/_models/package.py:124: TypeError
> ```

I make some changes on test codes to run on our new API, But there are some failure I am solving
xiacunshun: It seems like pre-commit abandon importing private module in `pip`. The CI tests can not go on.
xiacunshun: As mentioned [here](https://setuptools.pypa.io/en/latest/pkg_resources.html#distribution-attributes), which says that `key` is lower case of `project_name` and since `Distribution` and `Requirement` don't have `key` anymore, I am puzzled about the necessity of the continued existence of the attr `key`.
```
key
dist.key is short for dist.project_name.lower(). It’s used for case-insensitive comparison and indexing of distributions by project name.
```
xiacunshun: The left testcases are all checking `project_name` and `key`. For example
```
        graph: dict[tuple[str, str], list[tuple[str, list[tuple[str, str]]]]] = {
            ("examplePy", "1.2.3"): [("hellopy", [(">=", "2.0.0")])],
            ("HelloPy", "2.2.0"): [],
        }
        package_dag = PackageDAG.from_pkgs(list(mock_pkgs(graph)))
        parent_key = "examplepy"
        c = package_dag.get_children(parent_key)
        assert len(c) == 1
>       assert c[0].project_name == "HelloPy"
E       AssertionError: assert 'hellopy' == 'HelloPy'
E
E         - HelloPy
E         + hellopy
```
kemzeb: > It seems like pre-commit abandon importing private module in `pip`. The CI tests can not go on.

This is because ruff is complaining that you are importing private API. Let ignore the  "private import" rule in the lines it complains about by adding a comment i.e. `# noqa: PLC2701`.

For all the other complaints see if you can resolve them. If you can't, add a comment to ignore them.
xiacunshun: To be honest, fixing the `test_non_host` took me quite some time, because the errors from `main()` in the virtual environment were not captured. I was puzzled as to why the return value was an empty set! The problem turned out to be that a new dependency, packaging, was added, but it was not present in the virtual environment.
xiacunshun: Now most testcases passed except [this](https://github.com/tox-dev/pipdeptree/pull/333#discussion_r1536871750). (I don't know why my comment is "pending", I am not sure whether you can see it.) 
kemzeb: > Now most testcases passed except [this](https://github.com/tox-dev/pipdeptree/pull/333#discussion_r1536871750). (I don't know why my comment is "pending", I am not sure whether you can see it.)

Good work with getting the tests to pass! And the the failing test has to do with the old `DistPackage.project_name()`.  Notice that it mocks `importlib.metdata.metadata()` usage here. Since that code path (and just the overall method) no longer exists, I think we can remove it.
kemzeb: FYI I committed to this branch so you'll need to push the remote changes to your local; let me know if you need help with that 
kemzeb: Hey @xiacunshun, I committed to this branch to see if I can help you in some areas; you'll need to pull these changes to your local repository. Let me know if you need help with that!
xiacunshun: More testcases should be added to make the coverage check passed.
kemzeb: Good work thus far! Now that we pass all the existing tests, we should:
- Continue to iron out how to integrate pip's `FreezeRequirement` API with these new changes
- Add tests for the lines we don't cover (see the GitHub Action test failures, they tell you what lines need to be covered)

With those tasks done this PR should be good to go!
xiacunshun: ```
src/pipdeptree/_discovery.py (80.0%): Missing lines 23-24
src/pipdeptree/_models/dag.py (100%)
src/pipdeptree/_models/package.py (100%)
tests/_models/test_dag.py (100%)
tests/_models/test_package.py (100%)
tests/conftest.py (100%)
tests/test_discovery.py (88.5%): Missing lines 41-42,44
tests/test_non_host.py (80.0%): Missing lines 41
```
I am confused why `test_local_only` can not cover the lines 23-24 in src/pipdeptree/_discovery.py.
And the left ones in testcases are always hardly be touched. I am not sure why they can pass before.
```
elif implementation == "PyPy":
        expected = {"packaging", "cffi", "greenlet", "pip", "readline", "setuptools", "wheel", "munkres"}
    else:
        raise ValueError(implementation)
```
xiacunshun: I found another problem is that `distributions(path=XXX)`  can not work.
I test following code, but it returns nothing indeed.
```
dists = distributions(path="/usr/lib/python3.11/site-packages/")
for d in dists:
    print(d.name)
```
xiacunshun: I have did some trick in `test_local_only` to pretend that it is running in venv and that works!
And I also install a random package (e.g. wrapt) to make sure that we truely get the package which only in venv.
xiacunshun: The coverage come up to 99% now, but the left ones may be ignored I think?
```
src/pipdeptree/_discovery.py (100%)
src/pipdeptree/_models/dag.py (100%)
src/pipdeptree/_models/package.py (100%)
tests/_models/test_dag.py (100%)
tests/_models/test_package.py (100%)
tests/conftest.py (100%)
tests/test_discovery.py (96.2%): Missing lines 41
tests/test_non_host.py (80.0%): Missing lines 41
-------------
Total:   205 lines
Missing: 2 lines
Coverage: 99%
```
kemzeb: Going to looks for any further issues when I get the time and afterwards merge this 👍
kemzeb: Found a problem with how the frozen requirements were being generated. We were outputting `===` (or [arbitrary equality](https://packaging.python.org/en/latest/specifications/version-specifiers/#arbitrary-equality) instead of the normal `==`. From the spec they explain that this is highly discouraged, so I just decided to throw all of our existing code in a wrapper class and use the `Version` class that pip wants.

I plan on merging this tonight if there are no additional problems!
gaborbernat: No objections from myself against you becoming a maintainer. 
kemzeb: > No objections from myself against you becoming a maintainer. @kemzeb can add you.

Sure, I'll see if I can do add you after the PR has been merged.
xiacunshun: > > No objections from myself against you becoming a maintainer. @kemzeb can add you.
> 
> Sure, I'll see if I can add you after the PR has been merged.

That's wonderful! Thank you very much!
kemzeb: Added another change since we also were considering packages that have not been installed because their environment markers evaluate to false in our environment (e.g. `"importlib-metadata; python_version < '3.8'"` when I have Python v3.9):
```
$ pipdeptree
Warning!!! Possibly conflicting dependencies found:
* GitPython==3.1.41
 - typing-extensions [required: >=3.7.4.3, installed: ?]
* pipdeptree==2.16.3.dev2+gedf33d9.d20240401
 - pip [required: >=23.1.2, installed: 23.0.1]
* tox==4.14.2
 - importlib-metadata [required: >=7.0.1, installed: ?]
 - typing-extensions [required: >=4.9, installed: ?]
* virtualenv==20.25.1
 - importlib-metadata [required: >=6.6, installed: ?]
------------------------------------------------------------------------
covdefaults==2.3.0
└── coverage [required: >=6.0.2, installed: 7.4.4]
. . .
```

Recent changes calms it down:
```
$ pipdeptree
Warning!!! Possibly conflicting dependencies found:
* pipdeptree==2.16.3.dev2+gedf33d9.d20240401
 - pip [required: >=23.1.2, installed: 23.0.1]
------------------------------------------------------------------------
covdefaults==2.3.0
└── coverage [required: >=6.0.2, installed: 7.4.4]
. . .
```

Seeing the old pip-powered output, it looks like this mimics what it does when it finds packages that can't be installed in the current environment.
kemzeb: No, we shouldn't warn them since packages with environment markers essentially makes it conditional whether or not they should be installed.

If there is an environment marker that expects the package to be installed on a Windows machine, package managers won't install this package on a MacOS machine. So there shouldn't be a warning (just like how it was handled before when dealing with the pip API).

xiacunshun: > No, we shouldn't warn them since packages with environment markers essentially makes it conditional whether or not they should be installed.
> 
> If there is an environment marker that expects the package to be installed on a Windows machine, package managers won't install this package on a MacOS machine. So there shouldn't be a warning (just like how it was handled before when dealing with the pip API).

Got it, it makes sense.
kemzeb: I think everything looks good. The only problem I found just now has to do with using `--python` option:
```
$ pipdeptree --python venv/bin/python
Traceback (most recent call last):
  File "/usr/local/lib/python3.9/runpy.py", line 197, in _run_module_as_main
    return _run_code(code, main_globals, None,
  File "/usr/local/lib/python3.9/runpy.py", line 87, in _run_code
    exec(code, run_globals)
  File "/tmp/tmpy7de5xnt/pipdeptree/__main__.py", line 10, in <module>
    from pipdeptree._models import PackageDAG
  File "/tmp/tmpy7de5xnt/pipdeptree/_models/__init__.py", line 3, in <module>
    from .dag import PackageDAG, ReversedPackageDAG
  File "/tmp/tmpy7de5xnt/pipdeptree/_models/dag.py", line 12, in <module>
    from .package import DistPackage, ReqPackage, pep503_normalize
  File "/tmp/tmpy7de5xnt/pipdeptree/_models/package.py", line 10, in <module>
    from packaging.requirements import Requirement
ModuleNotFoundError: No module named 'packaging'
```

Since we now depend on `packaging`, it looks like it needs to be included in the virtual environment. An easy way to solve this is to head over to `_non_host.py` and make sure `packaging` exists (i.e. `/path/to/venv python -m pip install packaging` or something similar). Haven't found a better approach to this yet.
xiacunshun: I think this error may not need to be solved, because if you need to use non-host python which uses the dependencies in that env, so the user should ensure that the necessary dependencies exist in that environment?
kemzeb: > I think this error may not need to be solved, because if you need to use non-host python which uses the dependencies in that env, so the user should ensure that the necessary dependencies exist in that environment?
> 
> edit: pip do have on-by-default cache, but it looks like it cannot be used in venv(they are separate).

I do see your point, but this changes how `--python` is ran, and when users were to upgrade to a newer version they would need to perform one more step before they can actually run `pipdeptree` which I think is not ideal.

Right now I think I see two approaches:
- Fire up a process to install packaging into the environment just before we run it i.e. /path/to/python -m pip install packaging
- Modify `PYTHONPATH` (see [here](https://docs.python.org/3/library/sys.html#index-23)) to somehow include our globally-installed `packaging`

Regarding how pip handles caching, it seems that pip uses a single cache no matter if we're using the system environment or a virtual environment, for example:
```
$ python -m venv venv
$ source venv/bin/activate
(venv) $ pip cache dir
/home/vscode/.cache/pip # <--- pip's cache is stored in the user home directory, even in a virtual env
(venv) $ pip install packaging
Collecting packaging
  Using cached packaging-24.0-py3-none-any.whl (53 kB)  # <--- We snatch packaging from the cache
Installing collected packages: packaging
Successfully installed packaging-24.0
```
xiacunshun: > Right now I think I see two approaches:
Fire up a process to install packaging into the environment just before we run it i.e. /path/to/python -m pip install packaging
Modify PYTHONPATH (see [here](https://docs.python.org/3/library/sys.html#sys.path)) to somehow include our globally-installed packaging

The second one is better I think, I will try this.
xiacunshun: > Regarding how pip handles caching, it seems that pip uses a single cache no matter if we're using the system environment or a virtual environment, for example:

You are right. I didn't notice it before. 
Another situation is that if the distribution is installed by `rpm` instead of `pip`, pip cache cannot be used.
```
Found existing installation: packaging 23.1
ERROR: Cannot uninstall packaging 23.1, RECORD file not found. Hint: The package was installed by rpm.
[root@linux tools]# source /root/myenv/bin/activate
(myenv) [root@linux tools]# 
(myenv) [root@linux tools]# 
(myenv) [root@linux tools]# pip uninstall packaging
WARNING: Skipping packaging as it is not installed.
(myenv) [root@linux tools]# pip install packaging
Collecting packaging
  Downloading packaging-24.0-py3-none-any.whl.metadata (3.2 kB) <===== need to download through network
Downloading packaging-24.0-py3-none-any.whl (53 kB)
   ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 53.5/53.5 kB 1.4 MB/s eta 0:00:00
Installing collected packages: packaging
Successfully installed packaging-24.0
```
We should better not to download something in our progress, even in non host cases.
kemzeb: > Another situation is that if the distribution is installed by rpm instead of pip, pip cache cannot be used.

I agree, and I think the latter approach is better since we don't have to perform any networking (as `packaging` should exist in whatever environment the user is in already if `pipdeptree` is installed) and we don't have to have users use pip to install it (as there maybe some users that use an alternative to pip/PyPI to install their packages).
kemzeb: Alright, after looking at this patch a couple more times I'm not seeing any other areas of concern so I think this is good to go. Thanks @xiacunshun for the contribution!
kemzeb: Hey @gaborbernat, it looks like since I was the latest one to push to this branch I'm gonna need your approval with this one. I understand you're busy, so please review this at your convenience. Thank you!

@xiacunshun Once this PR is merged, I'll go ahead and add you as a collaborator 👍

Patch:
diff --git a/pyproject.toml b/pyproject.toml
index 0ae038b..d925e8c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -40,6 +40,7 @@ dynamic = [
   "version",
 ]
 dependencies = [
+  "packaging>=23.1",
   "pip>=23.1.2",
 ]
 optional-dependencies.graphviz = [
diff --git a/src/pipdeptree/_adapter.py b/src/pipdeptree/_adapter.py
new file mode 100644
index 0000000..9db084b
--- /dev/null
+++ b/src/pipdeptree/_adapter.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+from json import JSONDecodeError
+from pathlib import Path
+from typing import TYPE_CHECKING
+
+from pip._internal.models.direct_url import (
+    DirectUrl,  # noqa: PLC2701
+    DirectUrlValidationError,  # noqa: PLC2701
+)
+from pip._internal.utils.egg_link import egg_link_path_from_sys_path  # noqa: PLC2701
+from pip._vendor.packaging.version import Version  # noqa: PLC2701
+
+if TYPE_CHECKING:
+    from importlib.metadata import Distribution
+
+
+class PipBaseDistributionAdapter:
+    """
+    An adapter class for pip's `pip._internal.metadata.BaseDistribution` abstract class.
+
+    It essentially wraps over an importlib.metadata.Distribution object and provides just enough fields/methods found in
+    pip's `BaseDistribution` so that we can use `pip._internal.operations.freeze.FrozenRequirement.from_dist()`.
+
+    :param dist: Represents an `importlib.metadata.Distribution` object.
+    """
+
+    DIRECT_URL_METADATA_NAME = "direct_url.json"
+
+    def __init__(self, dist: Distribution) -> None:
+        self._dist = dist
+        self._raw_name = dist.metadata["Name"]
+        self._version = Version(dist.version)
+
+    @property
+    def raw_name(self) -> str:
+        return self._raw_name
+
+    @property
+    def version(self) -> Version:
+        return self._version
+
+    @property
+    def editable(self) -> bool:
+        return self.editable_project_location is not None
+
+    @property
+    def direct_url(self) -> DirectUrl | None:
+        result = None
+        json_str = self._dist.read_text(self.DIRECT_URL_METADATA_NAME)
+        try:
+            if json_str:
+                result = DirectUrl.from_json(json_str)
+        except (
+            UnicodeDecodeError,
+            JSONDecodeError,
+            DirectUrlValidationError,
+        ):
+            return result
+        return result
+
+    @property
+    def editable_project_location(self) -> str | None:
+        direct_url = self.direct_url
+        if direct_url and direct_url.is_local_editable():
+            from pip._internal.utils.urls import url_to_path  # noqa: PLC2701, PLC0415
+
+            return url_to_path(direct_url.url)
+
+        result = None
+        egg_link_path = egg_link_path_from_sys_path(self.raw_name)
+        if egg_link_path:
+            with Path(egg_link_path).open("r") as f:
+                result = f.readline().rstrip()
+        return result
diff --git a/src/pipdeptree/_discovery.py b/src/pipdeptree/_discovery.py
index 53b5de5..1773963 100644
--- a/src/pipdeptree/_discovery.py
+++ b/src/pipdeptree/_discovery.py
@@ -1,35 +1,29 @@
 from __future__ import annotations
 
+import site
+import sys
+from importlib.metadata import distributions
 from typing import TYPE_CHECKING
 
 if TYPE_CHECKING:
-    from pip._vendor.pkg_resources import DistInfoDistribution
+    from importlib.metadata import Distribution
 
 
 def get_installed_distributions(
     local_only: bool = False,  # noqa: FBT001, FBT002
     user_only: bool = False,  # noqa: FBT001, FBT002
-) -> list[DistInfoDistribution]:
-    try:
-        from pip._internal.metadata import pkg_resources  # noqa: PLC0415, PLC2701
-    except ImportError:
-        # For backward compatibility with python ver. 2.7 and pip
-        # version 20.3.4 (the latest pip version that works with python
-        # version 2.7)
-        from pip._internal.utils import misc  # noqa: PLC0415, PLC2701 # pragma: no cover
-
-        return misc.get_installed_distributions(  # type: ignore[no-any-return,attr-defined]
-            local_only=local_only,
-            user_only=user_only,
-        )
-
-    else:
-        dists = pkg_resources.Environment.from_paths(None).iter_installed_distributions(
-            local_only=local_only,
-            skip=(),
-            user_only=user_only,
-        )
-        return [d._dist for d in dists]  # type: ignore[attr-defined] # noqa: SLF001
+) -> list[Distribution]:
+    if user_only:
+        return list(distributions(path=[site.getusersitepackages()]))
+
+    # NOTE: See https://docs.python.org/3/library/venv.html#how-venvs-work for more details.
+    in_venv = sys.prefix != sys.base_prefix
+
+    if local_only and in_venv:
+        venv_site_packages = site.getsitepackages([sys.prefix])
+        return list(distributions(path=venv_site_packages))
+
+    return list(distributions())
 
 
 __all__ = [
diff --git a/src/pipdeptree/_models/dag.py b/src/pipdeptree/_models/dag.py
index aafc667..4b0acb6 100644
--- a/src/pipdeptree/_models/dag.py
+++ b/src/pipdeptree/_models/dag.py
@@ -6,7 +6,7 @@ from itertools import chain
 from typing import TYPE_CHECKING, Iterator, List, Mapping
 
 if TYPE_CHECKING:
-    from pip._vendor.pkg_resources import DistInfoDistribution
+    from importlib.metadata import Distribution
 
 
 from .package import DistPackage, ReqPackage, pep503_normalize
@@ -36,19 +36,18 @@ class PackageDAG(Mapping[DistPackage, List[ReqPackage]]):
     """
 
     @classmethod
-    def from_pkgs(cls, pkgs: list[DistInfoDistribution]) -> PackageDAG:
+    def from_pkgs(cls, pkgs: list[Distribution]) -> PackageDAG:
         dist_pkgs = [DistPackage(p) for p in pkgs]
         idx = {p.key: p for p in dist_pkgs}
         m: dict[DistPackage, list[ReqPackage]] = {}
         for p in dist_pkgs:
             reqs = []
             for r in p.requires():
-                # Requirement key is not sufficiently normalized in pkg_resources - apply additional normalization
-                d = idx.get(pep503_normalize(r.key))
-                # pip's _vendor.packaging.requirements.Requirement uses the exact casing of a dependency's name found in
-                # a project's build config, which is not ideal when rendering.
-                # See https://github.com/tox-dev/pipdeptree/issues/242
-                r.project_name = d.project_name if d is not None else r.project_name
+                d = idx.get(pep503_normalize(r.name))
+                # Distribution.requires only return the name of requirements in metadata file, which may not be
+                # the same with the capitalized one in pip. We should retain the casing of required package name.
+                # see https://github.com/tox-dev/pipdeptree/issues/242
+                r.name = d.project_name if d is not None else r.name
                 pkg = ReqPackage(r, d)
                 reqs.append(pkg)
             m[p] = reqs
@@ -110,16 +109,11 @@ class PackageDAG(Mapping[DistPackage, List[ReqPackage]]):
         if include is None and exclude is None:
             return self
 
-        # Note: In following comparisons, we use lower cased values so
-        # that user may specify `key` or `project_name`. As per the
-        # documentation, `key` is simply
-        # `project_name.lower()`. Refer:
-        # https://setuptools.readthedocs.io/en/latest/pkg_resources.html#distribution-objects
         include_with_casing_preserved: list[str] = []
         if include:
             include_with_casing_preserved = include
-            include = [s.lower() for s in include]
-        exclude = {s.lower() for s in exclude} if exclude else set()
+            include = [pep503_normalize(i) for i in include]
+        exclude = {pep503_normalize(s) for s in exclude} if exclude else set()
 
         # Check for mutual exclusion of show_only and exclude sets
         # after normalizing the values to lowercase
@@ -164,7 +158,9 @@ class PackageDAG(Mapping[DistPackage, List[ReqPackage]]):
                             # a dependency is missing
                             continue
 
-        non_existent_includes = [i for i in include_with_casing_preserved if i.lower() not in matched_includes]
+        non_existent_includes = [
+            i for i in include_with_casing_preserved if pep503_normalize(i) not in matched_includes
+        ]
         if non_existent_includes:
             raise ValueError("No packages matched using the following patterns: " + ", ".join(non_existent_includes))
 
@@ -242,7 +238,7 @@ class ReversedPackageDAG(PackageDAG):
             for v in vs:
                 assert isinstance(v, DistPackage)
                 node = next((p for p in m if p.key == v.key), v.as_parent_of(None))
-                m[node].append(k)  # type: ignore[arg-type]
+                m[node].append(k)
             if k.key not in child_keys:
                 assert isinstance(k, ReqPackage)
                 assert k.dist is not None
diff --git a/src/pipdeptree/_models/package.py b/src/pipdeptree/_models/package.py
index afd7f4c..65df5a9 100644
--- a/src/pipdeptree/_models/package.py
+++ b/src/pipdeptree/_models/package.py
@@ -3,19 +3,20 @@ from __future__ import annotations
 import re
 from abc import ABC, abstractmethod
 from importlib import import_module
-from importlib.metadata import PackageNotFoundError, metadata, version
+from importlib.metadata import Distribution, PackageNotFoundError, metadata, version
 from inspect import ismodule
 from typing import TYPE_CHECKING
 
-from pip._vendor.pkg_resources import Requirement  # noqa: PLC2701
+from packaging.requirements import Requirement
 
 if TYPE_CHECKING:
-    from pip._internal.metadata import BaseDistribution
-    from pip._vendor.pkg_resources import DistInfoDistribution
+    from importlib.metadata import Distribution
+
+from pipdeptree._adapter import PipBaseDistributionAdapter
 
 
 def pep503_normalize(name: str) -> str:
-    return re.sub("[-_.]+", "-", name)
+    return re.sub("[-_.]+", "-", name).lower()
 
 
 class Package(ABC):
@@ -23,13 +24,9 @@ class Package(ABC):
 
     UNKNOWN_LICENSE_STR = "(Unknown license)"
 
-    def __init__(self, obj: DistInfoDistribution) -> None:
-        self._obj: DistInfoDistribution = obj
-        self.key = pep503_normalize(obj.key)
-
-    @property
-    def project_name(self) -> str:
-        return self._obj.project_name  # type: ignore[no-any-return]
+    def __init__(self, project_name: str) -> None:
+        self.project_name = project_name
+        self.key = pep503_normalize(project_name)
 
     def licenses(self) -> str:
         try:
@@ -73,32 +70,12 @@ class Package(ABC):
         return render(frozen=frozen)
 
     @staticmethod
-    def as_frozen_repr(obj: DistInfoDistribution) -> str:
-        # The `pip._internal.metadata` modules were introduced in 21.1.1
-        # and the `pip._internal.operations.freeze.FrozenRequirement`
-        # class now expects dist to be a subclass of
-        # `pip._internal.metadata.BaseDistribution`, however the
-        # `pip._internal.utils.misc.get_installed_distributions` continues
-        # to return objects of type
-        # pip._vendor.pkg_resources.DistInfoDistribution.
-        #
-        # This is a hacky backward compatible (with older versions of pip) fix.
-        try:
-            from pip._internal.operations.freeze import FrozenRequirement  # noqa: PLC0415 # pragma: no cover
-        except ImportError:
-            from pip import FrozenRequirement  # type: ignore[attr-defined, no-redef] # noqa: PLC0415 # pragma: no cover
+    def as_frozen_repr(dist: Distribution) -> str:
+        from pip._internal.operations.freeze import FrozenRequirement  # noqa: PLC0415, PLC2701 # pragma: no cover
 
-        try:
-            from pip._internal import metadata  # noqa: PLC0415, PLC2701 # pragma: no cover
-        except ImportError:
-            our_dist: BaseDistribution = obj  # type: ignore[assignment]
-        else:
-            our_dist = metadata.pkg_resources.Distribution(obj)
+        adapter = PipBaseDistributionAdapter(dist)
+        fr = FrozenRequirement.from_dist(adapter)  # type: ignore[arg-type]
 
-        try:
-            fr = FrozenRequirement.from_dist(our_dist)
-        except TypeError:
-            fr = FrozenRequirement.from_dist(our_dist, [])  # type: ignore[call-arg]
         return str(fr).strip()
 
     def __repr__(self) -> str:
@@ -110,39 +87,41 @@ class Package(ABC):
 
 class DistPackage(Package):
     """
-    Wrapper class for pkg_resources.Distribution instances.
+    Wrapper class for importlib.metadata.Distribution instances.
 
-    :param obj: pkg_resources.Distribution to wrap over
+    :param obj: importlib.metadata.Distribution to wrap over
     :param req: optional ReqPackage object to associate this DistPackage with. This is useful for displaying the tree in
         reverse
 
     """
 
-    def __init__(self, obj: DistInfoDistribution, req: ReqPackage | None = None) -> None:
-        super().__init__(obj)
+    def __init__(self, obj: Distribution, req: ReqPackage | None = None) -> None:
+        super().__init__(obj.metadata["Name"])
+        self._obj = obj
         self.req = req
-        self._project_name = ""
 
     def requires(self) -> list[Requirement]:
-        return self._obj.requires()  # type: ignore[no-untyped-call,no-any-return]
-
-    @property
-    def project_name(self) -> str:
-        if not self._project_name:
-            try:
-                self._project_name = metadata(self.key)["name"]
-            except (PackageNotFoundError, KeyError):
-                self._project_name = self._obj.project_name
-        return self._project_name
+        req_list = []
+        for r in self._obj.requires or []:
+            req = Requirement(r)
+            if not req.marker or req.marker.evaluate():
+                # Make sure that we're either dealing with a dependency that has no environment markers or does but
+                # are evaluated True against the existing environment (if it's False, it means they cannot be
+                # installed). "extra" markers are always evaluated False here which is what we want when retrieving
+                # only required dependencies.
+                req_list.append(req)
+        return req_list
 
     @property
     def version(self) -> str:
-        return self._obj.version  # type: ignore[no-any-return]
+        return self._obj.version
+
+    def unwrap(self) -> Distribution:
+        """Exposes the internal `importlib.metadata.Distribution` object."""
+        return self._obj
 
     def render_as_root(self, *, frozen: bool) -> str:
-        if not frozen:
-            return f"{self.project_name}=={self.version}"
-        return self.as_frozen_repr(self._obj)
+        return self.as_frozen_repr(self._obj) if frozen else f"{self.project_name}=={self.version}"
 
     def render_as_branch(self, *, frozen: bool) -> str:
         assert self.req is not None
@@ -156,7 +135,8 @@ class DistPackage(Package):
 
     def as_requirement(self) -> ReqPackage:
         """Return a ReqPackage representation of this DistPackage."""
-        return ReqPackage(self._obj.as_requirement(), dist=self)  # type: ignore[no-untyped-call]
+        spec = f"{self.project_name}=={self.version}"
+        return ReqPackage(Requirement(spec), dist=self)
 
     def as_parent_of(self, req: ReqPackage | None) -> DistPackage:
         """
@@ -180,24 +160,25 @@ class DistPackage(Package):
 
 class ReqPackage(Package):
     """
-    Wrapper class for Requirements instance.
+    Wrapper class for Requirement instance.
 
-    :param obj: The `Requirements` instance to wrap over
-    :param dist: optional `pkg_resources.Distribution` instance for this requirement
+    :param obj: The `Requirement` instance to wrap over
+    :param dist: optional `importlib.metadata.Distribution` instance for this requirement
 
     """
 
     UNKNOWN_VERSION = "?"
 
     def __init__(self, obj: Requirement, dist: DistPackage | None = None) -> None:
-        super().__init__(obj)
+        super().__init__(obj.name)
+        self._obj = obj
         self.dist = dist
 
     def render_as_root(self, *, frozen: bool) -> str:
         if not frozen:
             return f"{self.project_name}=={self.installed_version}"
         if self.dist:
-            return self.as_frozen_repr(self.dist._obj)  # noqa: SLF001
+            return self.as_frozen_repr(self.dist.unwrap())
         return self.project_name
 
     def render_as_branch(self, *, frozen: bool) -> str:
@@ -208,8 +189,11 @@ class ReqPackage(Package):
 
     @property
     def version_spec(self) -> str | None:
-        specs = sorted(self._obj.specs, reverse=True)  # `reverse` makes '>' prior to '<'
-        return ",".join(["".join(sp) for sp in specs]) if specs else None
+        result = None
+        specs = sorted(map(str, self._obj.specifier), reverse=True)  # `reverse` makes '>' prior to '<'
+        if specs:
+            result = ",".join(specs)
+        return result
 
     @property
     def installed_version(self) -> str:
@@ -241,10 +225,8 @@ class ReqPackage(Package):
         # unknown installed version is also considered conflicting
         if self.installed_version == self.UNKNOWN_VERSION:
             return True
-        ver_spec = self.version_spec if self.version_spec else ""
-        req_version_str = f"{self.project_name}{ver_spec}"
-        req_obj = Requirement.parse(req_version_str)  # type: ignore[no-untyped-call]
-        return self.installed_version not in req_obj
+
+        return self.installed_version not in self._obj.specifier
 
     def as_dict(self) -> dict[str, str]:
         return {
diff --git a/src/pipdeptree/_non_host.py b/src/pipdeptree/_non_host.py
index 0f630af..519275e 100644
--- a/src/pipdeptree/_non_host.py
+++ b/src/pipdeptree/_non_host.py
@@ -39,10 +39,19 @@ def handle_non_host_target(args: Options) -> int | None:
         with TemporaryDirectory() as project:
             dest = Path(project)
             copytree(our_root, dest / "pipdeptree")
-            # invoke from an empty folder to avoid cwd altering sys.path
-            env = os.environ.copy()
-            env["PYTHONPATH"] = project
+
+            # We need to also make the Python executable aware of packaging since we use it.
+            packaging_src = getsourcefile(sys.modules["packaging"])
+            assert packaging_src is not None
+            packaging_root = Path(packaging_src).parent
+            copytree(packaging_root, dest / "packaging")
+
             cmd = [str(py_path), "-m", "pipdeptree", *argv]
+            env = os.environ.copy()
+
+            # The cwd is prepended to `sys.path` when executing __main__ using `python -m` (meaning we prepend the tmp
+            # directory `project` here).
+            # See https://docs.python.org/3/library/sys.html#sys.path
             return call(cmd, cwd=project, env=env)  # noqa: S603
     return None
 


Test patch:
diff --git a/tests/_models/test_dag.py b/tests/_models/test_dag.py
index 0680896..b696669 100644
--- a/tests/_models/test_dag.py
+++ b/tests/_models/test_dag.py
@@ -1,15 +1,15 @@
 from __future__ import annotations
 
-from importlib.metadata import PackageNotFoundError
 from itertools import chain
 from typing import TYPE_CHECKING, Any, Callable, Iterator
-from unittest.mock import Mock
 
 import pytest
 
 from pipdeptree._models import DistPackage, PackageDAG, ReqPackage, ReversedPackageDAG
 
 if TYPE_CHECKING:
+    from unittest.mock import Mock
+
     from tests.our_types import MockGraph
 
 
@@ -72,6 +72,21 @@ def test_package_dag_filter_nonexistent_packages(t_fnmatch: PackageDAG) -> None:
         t_fnmatch.filter_nodes(["x", "y", "z"], None)
 
 
+def test_package_dag_filter_packages_uses_pep503normalize(
+    mock_pkgs: Callable[[MockGraph], Iterator[Mock]],
+) -> None:
+    graph: MockGraph = {
+        ("Pie.Pie", "1"): [],
+    }
+    pkgs = PackageDAG.from_pkgs(list(mock_pkgs(graph)))
+    pkgs = pkgs.filter_nodes(["Pie.Pie"], None)
+    assert len(pkgs) == 1
+    assert pkgs.get_node_as_parent("pie-pie") is not None
+
+    pkgs = pkgs.filter_nodes(None, {"Pie.Pie"})
+    assert len(pkgs) == 0
+
+
 def test_package_dag_reverse(example_dag: PackageDAG) -> None:
     def sort_map_values(m: dict[str, Any]) -> dict[str, Any]:
         return {k: sorted(v) for k, v in m.items()}
@@ -92,16 +107,10 @@ def test_package_dag_reverse(example_dag: PackageDAG) -> None:
     assert all(isinstance(v, ReqPackage) for v in chain.from_iterable(t2.values()))
 
 
-def test_package_dag_from_pkgs(
-    mock_pkgs: Callable[[MockGraph], Iterator[Mock]], monkeypatch: pytest.MonkeyPatch
-) -> None:
+def test_package_dag_from_pkgs(mock_pkgs: Callable[[MockGraph], Iterator[Mock]]) -> None:
     # when pip's _vendor.packaging.requirements.Requirement's requires() gives a lowercased package name but the actual
     # package name in PyPI is mixed case, expect the mixed case version
 
-    # Since DistPackage.project_name will try to use importlib.metadata to grab the project name, let's avoid using
-    # the environment and use the fallback project name (since we control it here anyways).
-    monkeypatch.setattr("pipdeptree._models.package.metadata", Mock(side_effect=PackageNotFoundError()))
-
     graph: dict[tuple[str, str], list[tuple[str, list[tuple[str, str]]]]] = {
         ("examplePy", "1.2.3"): [("hellopy", [(">=", "2.0.0")])],
         ("HelloPy", "2.2.0"): [],
@@ -113,13 +122,9 @@ def test_package_dag_from_pkgs(
     assert c[0].project_name == "HelloPy"
 
 
-def test_package_dag_from_pkgs_uses_pep503normalize(
-    mock_pkgs: Callable[[MockGraph], Iterator[Mock]], monkeypatch: pytest.MonkeyPatch
-) -> None:
+def test_package_dag_from_pkgs_uses_pep503normalize(mock_pkgs: Callable[[MockGraph], Iterator[Mock]]) -> None:
     # ensure that requirement gets matched with a dists even when it's key needs pep503 normalization to match
 
-    monkeypatch.setattr("pipdeptree._models.package.metadata", Mock(side_effect=PackageNotFoundError()))
-
     graph: dict[tuple[str, str], list[tuple[str, list[tuple[str, str]]]]] = {
         ("parent-package", "1.2.3"): [("flufl.lock", [(">=", "2.0.0")])],
         ("flufl-lock", "2.2.0"): [],
@@ -128,4 +133,4 @@ def test_package_dag_from_pkgs_uses_pep503normalize(
     parent_key = "parent-package"
     c = package_dag.get_children(parent_key)
     assert c[0].dist
-    assert c[0].project_name == "flufl-lock"
+    assert c[0].key == "flufl-lock"
diff --git a/tests/_models/test_package.py b/tests/_models/test_package.py
index c5a5be1..d0fb28e 100644
--- a/tests/_models/test_package.py
+++ b/tests/_models/test_package.py
@@ -1,8 +1,9 @@
 from __future__ import annotations
 
 from importlib.metadata import PackageNotFoundError
+from pathlib import Path
 from typing import TYPE_CHECKING, Any
-from unittest.mock import Mock
+from unittest.mock import MagicMock, Mock
 
 import pytest
 
@@ -19,34 +20,81 @@ def sort_map_values(m: dict[str, Any]) -> dict[str, Any]:
 
 def test_guess_version_setuptools(mocker: MockerFixture) -> None:
     mocker.patch("pipdeptree._models.package.version", side_effect=PackageNotFoundError)
-    result = ReqPackage(mocker.MagicMock(key="setuptools")).installed_version
+    r = MagicMock()
+    r.name = "setuptools"
+    result = ReqPackage(r).installed_version
     assert result == "?"
 
 
+def test_package_as_frozen_repr(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+    file_path = tmp_path / "foo.egg-link"
+    with Path(file_path).open("w") as f:
+        f.write("/A/B/foo")
+    monkeypatch.syspath_prepend(str(tmp_path))
+    json_text = '{"dir_info": {"editable": true}}'
+    foo = Mock(metadata={"Name": "foo"}, version="20.4.1")
+    foo.read_text = Mock(return_value=json_text)
+    dp = DistPackage(foo)
+    expected = "# Editable install with no version control (foo==20.4.1)\n-e /A/B/foo"
+    assert Package.as_frozen_repr(dp.unwrap()) == expected
+
+
+def test_dist_package_requires() -> None:
+    foo = Mock(
+        metadata={"Name": "foo"},
+        requires=["bar", "baz >=2.7.2"],
+    )
+    dp = DistPackage(foo)
+    reqs = dp.requires()
+    assert len(reqs) == 2
+
+
+def test_dist_package_requires_with_environment_markers_that_eval_to_false() -> None:
+    foo = Mock(
+        metadata={"Name": "foo"},
+        requires=['foo ; sys_platform == "NoexistOS"', "bar >=2.7.2 ; extra == 'testing'"],
+    )
+    dp = DistPackage(foo)
+    reqs = dp.requires()
+    assert len(reqs) == 0
+
+
 def test_dist_package_render_as_root() -> None:
-    foo = Mock(key="foo", project_name="foo", version="20.4.1")
+    foo = Mock(metadata={"Name": "foo"}, version="20.4.1")
     dp = DistPackage(foo)
     is_frozen = False
     assert dp.render_as_root(frozen=is_frozen) == "foo==20.4.1"
 
 
 def test_dist_package_render_as_branch() -> None:
-    foo = Mock(key="foo", project_name="foo", version="20.4.1")
-    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")])
+    foo = Mock(metadata={"Name": "foo"}, version="20.4.1")
+    bar = Mock(metadata={"Name": "bar"}, version="4.1.0")
+    bar_req = MagicMock(version="4.1.0", specifier=[">=4.0"])
+    bar_req.name = "bar"
     rp = ReqPackage(bar_req, dist=bar)
     dp = DistPackage(foo).as_parent_of(rp)
     is_frozen = False
     assert dp.render_as_branch(frozen=is_frozen) == "foo==20.4.1 [requires: bar>=4.0]"
 
 
+def test_dist_package_render_as_root_with_frozen() -> None:
+    json_text = '{"dir_info": {"editable": true}, "url": "file:///A/B/foo"}'
+    foo = Mock(metadata={"Name": "foo"}, version="20.4.1")
+    foo.read_text = Mock(return_value=json_text)
+    dp = DistPackage(foo)
+    is_frozen = True
+    expect = "# Editable install with no version control (foo==20.4.1)\n-e /A/B/foo"
+    assert dp.render_as_root(frozen=is_frozen) == expect
+
+
 def test_dist_package_as_parent_of() -> None:
-    foo = Mock(key="foo", project_name="foo", version="20.4.1")
+    foo = Mock(metadata={"Name": "foo"}, version="20.4.1")
     dp = DistPackage(foo)
     assert dp.req is 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")])
+    bar = Mock(metadata={"Name": "bar"}, version="4.1.0")
+    bar_req = MagicMock(version="4.1.0", specifier=[">=4.0"])
+    bar_req.name = "bar"
     rp = ReqPackage(bar_req, dist=bar)
     dp1 = dp.as_parent_of(rp)
     assert dp1._obj == dp._obj  # noqa: SLF001
@@ -57,7 +105,7 @@ def test_dist_package_as_parent_of() -> None:
 
 
 def test_dist_package_as_dict() -> None:
-    foo = Mock(key="foo", project_name="foo", version="1.3.2b1")
+    foo = Mock(metadata={"Name": "foo"}, version="1.3.2b1")
     dp = DistPackage(foo)
     result = dp.as_dict()
     expected = {"key": "foo", "package_name": "foo", "installed_version": "1.3.2b1"}
@@ -96,7 +144,7 @@ def test_dist_package_as_dict() -> None:
 )
 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", key="a"))
+    dist = DistPackage(Mock(metadata={"Name": "a"}))
     licenses_str = dist.licenses()
 
     assert licenses_str == expected_output
@@ -104,50 +152,60 @@ def test_dist_package_licenses(mocked_metadata: Mock, expected_output: str, monk
 
 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", key="a"))
+    dist = DistPackage(Mock(metadata={"Name": "a"}))
     licenses_str = dist.licenses()
 
     assert licenses_str == Package.UNKNOWN_LICENSE_STR
 
 
-def test_dist_package_project_name_recovered(monkeypatch: pytest.MonkeyPatch) -> None:
-    monkeypatch.setattr("pipdeptree._models.package.metadata", lambda _: {"name": "good"})
-    foo = Mock(key="foo", project_name="bad", version="20.4.1")
-    dp = DistPackage(foo)
-    assert dp.project_name == "good"
-
-
 def test_dist_package_key_pep503_normalized() -> None:
-    foobar = Mock(key="foo.bar", project_name="foo.bar", version="20.4.1")
+    foobar = Mock(metadata={"Name": "foo.bar"}, version="20.4.1")
     dp = DistPackage(foobar)
     assert dp.key == "foo-bar"
 
 
 def test_req_package_key_pep503_normalized() -> None:
-    bar_req = Mock(key="bar.bar-bar-bar", project_name="Bar.Bar-Bar_Bar", version="4.1.0", specs=[(">=", "4.0")])
+    bar_req = MagicMock(version="4.1.0", specifier=[">=4.0"])
+    bar_req.name = "bar.bar-bar-bar"
     rp = ReqPackage(bar_req)
     assert rp.key == "bar-bar-bar-bar"
 
 
 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")])
+    bar = Mock(metadata={"Name": "bar"}, version="4.1.0")
+    bar_req = MagicMock(version="4.1.0", specifier=[">=4.0"])
+    bar_req.name = "bar"
     rp = ReqPackage(bar_req, dist=bar)
     is_frozen = False
     assert rp.render_as_root(frozen=is_frozen) == "bar==4.1.0"
 
 
+def test_req_package_render_as_root_with_frozen() -> None:
+    json_text = '{"dir_info": {"editable": true}, "url": "file:///A/B/bar"}'
+    bar = Mock(metadata={"Name": "bar"}, version="4.1.0")
+    bar.read_text = Mock(return_value=json_text)
+    d = DistPackage(bar)
+    bar_req = MagicMock(version="4.1.0", specifier=[">=4.0"])
+    bar_req.name = "bar"
+    rp = ReqPackage(bar_req, dist=d)
+    is_frozen = True
+    expect = "# Editable install with no version control (bar==4.1.0)\n-e /A/B/bar"
+    assert rp.render_as_root(frozen=is_frozen) == expect
+
+
 def test_req_package_render_as_branch() -> 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")])
+    bar = Mock(metadata={"Name": "bar"}, version="4.1.0")
+    bar_req = MagicMock(version="4.1.0", specifier=[">=4.0"])
+    bar_req.name = "bar"
     rp = ReqPackage(bar_req, dist=bar)
     is_frozen = False
     assert rp.render_as_branch(frozen=is_frozen) == "bar [required: >=4.0, installed: 4.1.0]"
 
 
 def test_req_package_as_dict() -> 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")])
+    bar = Mock(metadata={"Name": "bar"}, version="4.1.0")
+    bar_req = MagicMock(version="4.1.0", specifier=[">=4.0"])
+    bar_req.name = "bar"
     rp = ReqPackage(bar_req, dist=bar)
     result = rp.as_dict()
     expected = {"key": "bar", "package_name": "bar", "installed_version": "4.1.0", "required_version": ">=4.0"}
@@ -155,8 +213,9 @@ def test_req_package_as_dict() -> None:
 
 
 def test_req_package_as_dict_with_no_version_spec() -> 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=[])
+    bar = Mock(key="bar", version="4.1.0")
+    bar_req = MagicMock(version="4.1.0", specifier=[])
+    bar_req.name = "bar"
     rp = ReqPackage(bar_req, dist=bar)
     result = rp.as_dict()
     expected = {"key": "bar", "package_name": "bar", "installed_version": "4.1.0", "required_version": "Any"}
diff --git a/tests/conftest.py b/tests/conftest.py
index b47c9d2..0c00092 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -2,7 +2,7 @@ from __future__ import annotations
 
 from random import shuffle
 from typing import TYPE_CHECKING, Callable, Iterator
-from unittest.mock import Mock
+from unittest.mock import MagicMock, Mock
 
 import pytest
 
@@ -17,14 +17,19 @@ def mock_pkgs() -> Callable[[MockGraph], Iterator[Mock]]:
     def func(simple_graph: MockGraph) -> Iterator[Mock]:
         for node, children in simple_graph.items():
             nk, nv = node
-            m = Mock(key=nk.lower(), project_name=nk, version=nv)
-            as_req = Mock(key=nk, project_name=nk, specs=[("==", nv)])
+            m = Mock(metadata={"Name": nk}, version=nv)
+            as_req = MagicMock(specifier=[("==", nv)])
+            as_req.name = nk
             m.as_requirement = Mock(return_value=as_req)
             reqs = []
             for ck, cv in children:
-                r = Mock(key=ck, project_name=ck, specs=cv)
+                r = ck
+                for item in cv:
+                    if item:
+                        rs, rv = item
+                        r = r + rs + rv + ","
                 reqs.append(r)
-            m.requires = Mock(return_value=reqs)
+            m.requires = reqs
             yield m
 
     return func
diff --git a/tests/test_discovery.py b/tests/test_discovery.py
new file mode 100644
index 0000000..54bf553
--- /dev/null
+++ b/tests/test_discovery.py
@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+import site
+import sys
+from pathlib import Path
+from typing import TYPE_CHECKING
+from unittest.mock import Mock
+
+import virtualenv
+
+from pipdeptree.__main__ import main
+
+if TYPE_CHECKING:
+    import pytest
+
+
+def test_local_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capfd: pytest.CaptureFixture[str]) -> None:
+    venv_path = str(tmp_path / "venv")
+    result = virtualenv.cli_run([venv_path, "--activators", ""])
+    venv_site_packages = site.getsitepackages([venv_path])
+    fake_dist = Path(venv_site_packages[0]) / "foo-1.2.5.dist-info"
+    fake_dist.mkdir()
+    fake_metadata = Path(fake_dist) / "METADATA"
+    with fake_metadata.open("w") as f:
+        f.write("Metadata-Version: 2.3\n" "Name: foo\n" "Version: 1.2.5\n")
+
+    cmd = [str(result.creator.exe.parent / "python3"), "--local-only"]
+    monkeypatch.setattr(sys, "prefix", venv_path)
+    monkeypatch.setattr(sys, "argv", cmd)
+    main()
+    out, _ = capfd.readouterr()
+    found = {i.split("==")[0] for i in out.splitlines()}
+    expected = {"foo", "pip", "setuptools", "wheel"}
+    if sys.version_info >= (3, 12):
+        expected -= {"setuptools", "wheel"}  # pragma: no cover
+
+    assert found == expected
+
+
+def test_user_only(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capfd: pytest.CaptureFixture[str]) -> None:
+    fake_dist = Path(tmp_path) / "foo-1.2.5.dist-info"
+    fake_dist.mkdir()
+    fake_metadata = Path(fake_dist) / "METADATA"
+    with Path(fake_metadata).open("w") as f:
+        f.write("Metadata-Version: 2.3\n" "Name: foo\n" "Version: 1.2.5\n")
+
+    monkeypatch.setattr(site, "getusersitepackages", Mock(return_value=str(tmp_path)))
+    cmd = [sys.executable, "--user-only"]
+    monkeypatch.setattr(sys, "argv", cmd)
+    main()
+    out, _ = capfd.readouterr()
+    found = {i.split("==")[0] for i in out.splitlines()}
+    expected = {"foo"}
+
+    assert found == expected
diff --git a/tests/test_non_host.py b/tests/test_non_host.py
index 01f7653..782d3c5 100644
--- a/tests/test_non_host.py
+++ b/tests/test_non_host.py
@@ -33,7 +33,7 @@ def test_custom_interpreter(
     if implementation == "CPython":
         expected = {"pip", "setuptools", "wheel"}
     elif implementation == "PyPy":
-        expected = {"cffi", "greenlet", "pip", "readline", "setuptools", "wheel"}
+        expected = {"cffi", "greenlet", "pip", "readline", "setuptools", "wheel"}  # pragma: no cover
     else:
         raise ValueError(implementation)
     if sys.version_info >= (3, 12):