Repository: pypa/pipenv

Issue:
Uninstall of python-dotenv does not (really) work
### Issue description

I also tried reaching out on [stackoverflow](https://stackoverflow.com/questions/75999036/pipenv-python-dotenv-reappearing-after-every-pipenv-install?noredirect=1#comment134040336_75999036), but at this point. it feels like a bug

version: pipenv, version 2023.3.20

I try to uninstall python-dotenv. Should be easy, right? It's not in my Pipfile, and in my pipenv-graph it's not listed as a transitive dependency.

Following this series of commands:

<details>

  ```
$ echo "graph"; pipenv graph | grep python-dotenv; echo "Pipfile.lock"; cat Pipfile.lock | grep python-dotenv; echo "Pipfile"; cat Pipfile | grep python-dotenv   
  graph
  Loading .env environment variables...
  python-dotenv==1.0.0
  Pipfile.lock
          "python-dotenv": {
  Pipfile
  $ pipenv uninstall python-dotenv
  Loading .env environment variables...
  Removing python-dotenv from Pipfile.lock...
  Uninstalling python-dotenv...
  Found existing installation: python-dotenv 1.0.0
  Uninstalling python-dotenv-1.0.0:
    Successfully uninstalled python-dotenv-1.0.0
  
  Locking [packages] dependencies...
  Building requirements...
  Resolving dependencies...
  ✔ Success!
  Locking [dev-packages] dependencies...                                                                                                                                                                                                                               
  Building requirements...
  Resolving dependencies...
  ✔ Success!
  Updated Pipfile.lock (9362bc95e99217f2f5cac841a8b99b7b491e5c52886bf094ff10df3874969e05)!                                                                                                                                                                             
  $ echo "graph"; pipenv graph | grep python-dotenv; echo "Pipfile.lock"; cat Pipfile.lock | grep python-dotenv; echo "Pipfile"; cat Pipfile | grep python-dotenv   
  graph
  Loading .env environment variables...
  Pipfile.lock
          "python-dotenv": {
  Pipfile

```

</details>

notice, how it's NOT deleted from the Pipfile.lock?, well, if i now run "pipenv install", I went full circle and have it back in my pipenv graph.

I thought removing it manually from the pipfile lock would work, but after running "pipenv lock" (or any other command that recreates the lockfile) it's back in there again, even if it was not present in the Pipfile nor pipenv graph.

### Expected result

the package being actually removed, and not reappearing again

-------------------------------------------------------------------------------

pipenv --support is way to large to paste here
[pipenv__support.txt](https://github.com/pypa/pipenv/files/11225798/pipenv__support.txt)



Discussion:
@soraphis Ok I spent a bit debugging this, and it seems it is because you have `django-dotenv = "*"` in your `Pipfile` which certainly requires `python-dotenv`  -- I am not sure why its showing up as a top level item in the graph though.
Sorry, I was slightly off base in my last response, the real reason its included is `uvicorn`:  https://github.com/encode/uvicorn/blob/master/pyproject.toml#L41
Thanks for taking the time and looking into it. What I don't really get is: why isn't showing up in the graph? Other dependencies are also listed multiple times if required by multiple packages... 
For the record, I tested this the other day out of curiosity. PDM also installs `python-dotenv` when importing from this `Pipfile`, but doesn't list it in `pdm list --graph` at all, which I thought was very strange.

Resolving patch:
diff --git a/pipenv/__init__.py b/pipenv/__init__.py
--- a/pipenv/__init__.py
+++ b/pipenv/__init__.py
@@ -14,10 +14,13 @@
 warnings.filterwarnings("ignore", category=ResourceWarning)
 warnings.filterwarnings("ignore", category=UserWarning)
 PIPENV_ROOT = os.path.abspath(os.path.dirname(os.path.realpath(__file__)))
+PIPENV_VENDOR = os.sep.join([PIPENV_ROOT, "vendor"])
 PIP_VENDOR = os.sep.join([PIPENV_ROOT, "patched", "pip", "_vendor"])
 
 # Load patched pip instead of system pip
 os.environ["PIP_DISABLE_PIP_VERSION_CHECK"] = "1"
+sys.path.insert(0, PIPENV_ROOT)
+sys.path.insert(0, PIPENV_VENDOR)
 sys.path.insert(0, PIP_VENDOR)
 
 if os.name == "nt":
diff --git a/pipenv/vendor/pipdeptree/__init__.py b/pipenv/vendor/pipdeptree/__init__.py
--- a/pipenv/vendor/pipdeptree/__init__.py
+++ b/pipenv/vendor/pipdeptree/__init__.py
@@ -462,16 +462,90 @@ def render_text(tree, list_all=True, frozen=False):
     :param bool list_all: whether to list all the pgks at the root level or only those that are the sub-dependencies
     :param bool frozen: show the names of the pkgs in the output that's favourable to pip --freeze
     :returns: None
-
     """
     tree = tree.sort()
     nodes = tree.keys()
     branch_keys = {r.key for r in chain.from_iterable(tree.values())}
-    use_bullets = not frozen
 
     if not list_all:
         nodes = [p for p in nodes if p.key not in branch_keys]
 
+    if sys.stdout.encoding.lower() in ("utf-8", "utf-16", "utf-32"):
+        _render_text_with_unicode(tree, nodes, frozen)
+    else:
+        _render_text_without_unicode(tree, nodes, frozen)
+
+
+def _render_text_with_unicode(tree, nodes, frozen):
+    use_bullets = not frozen
+
+    def aux(
+        node,
+        parent=None,
+        indent=0,
+        cur_chain=None,
+        prefix="",
+        depth=0,
+        has_grand_parent=False,
+        is_last_child=False,
+        parent_is_last_child=False,
+    ):
+        cur_chain = cur_chain or []
+        node_str = node.render(parent, frozen)
+        next_prefix = ""
+        next_indent = indent + 2
+
+        if parent:
+            bullet = "├── "
+            if is_last_child:
+                bullet = "└── "
+
+            line_char = "│"
+            if not use_bullets:
+                line_char = ""
+                # Add 2 spaces so direct dependencies to a project are indented
+                bullet = "  "
+
+            if has_grand_parent:
+                next_indent -= 1
+                if parent_is_last_child:
+                    offset = 0 if len(line_char) == 1 else 1
+                    prefix += " " * (indent + 1 - offset - depth)
+                else:
+                    prefix += line_char + " " * (indent - depth)
+                # Without this extra space, bullets will point to the space just before the project name
+                prefix += " " if use_bullets else ""
+            next_prefix = prefix
+            node_str = prefix + bullet + node_str
+        result = [node_str]
+
+        children = tree.get_children(node.key)
+        children_strings = [
+            aux(
+                c,
+                node,
+                indent=next_indent,
+                cur_chain=cur_chain + [c.project_name],
+                prefix=next_prefix,
+                depth=depth + 1,
+                has_grand_parent=parent is not None,
+                is_last_child=c is children[-1],
+                parent_is_last_child=is_last_child,
+            )
+            for c in children
+            if c.project_name not in cur_chain
+        ]
+
+        result += list(chain.from_iterable(children_strings))
+        return result
+
+    lines = chain.from_iterable([aux(p) for p in nodes])
+    print("\n".join(lines))
+
+
+def _render_text_without_unicode(tree, nodes, frozen):
+    use_bullets = not frozen
+
     def aux(node, parent=None, indent=0, cur_chain=None):
         cur_chain = cur_chain or []
         node_str = node.render(parent, frozen)
@@ -886,7 +960,7 @@ def get_parser():
         "--mermaid",
         action="store_true",
         default=False,
-        help=("Display dependency tree as a Maermaid graph. " "This option overrides all other options."),
+        help=("Display dependency tree as a Mermaid graph. " "This option overrides all other options."),
     )
     parser.add_argument(
         "--graph-output",
diff --git a/pipenv/vendor/pipdeptree/version.py b/pipenv/vendor/pipdeptree/version.py
--- a/pipenv/vendor/pipdeptree/version.py
+++ b/pipenv/vendor/pipdeptree/version.py
@@ -1,4 +1,4 @@
 # file generated by setuptools_scm
 # don't change, don't track in version control
-__version__ = version = '2.7.0'
-__version_tuple__ = version_tuple = (2, 7, 0)
+__version__ = version = '2.8.0'
+__version_tuple__ = version_tuple = (2, 8, 0)


Test patch:
diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py
--- a/tests/integration/test_cli.py
+++ b/tests/integration/test_cli.py
@@ -105,10 +105,6 @@ def test_pipenv_graph_reverse(pipenv_instance_private_pypi):
         assert c.returncode == 0
         output = c.stdout
 
-        c = p.pipenv('graph --reverse --json')
-        assert c.returncode == 1
-        assert 'Warning: Using both --reverse and --json together is not supported.' in c.stderr
-
         requests_dependency = [
             ('backports.csv', 'backports.csv'),
             ('odfpy', 'odfpy'),
@@ -119,7 +115,7 @@ def test_pipenv_graph_reverse(pipenv_instance_private_pypi):
         ]
 
         for dep_name, dep_constraint in requests_dependency:
-            pat = fr'^[ -]*{dep_name}==[\d.]+'
+            pat = fr'{dep_name}==[\d.]+'
             dep_match = re.search(pat, output, flags=re.MULTILINE)
             assert dep_match is not None, f'{pat} not found in {output}'
 
@@ -128,12 +124,12 @@ def test_pipenv_graph_reverse(pipenv_instance_private_pypi):
                 openpyxl_dep = re.search(r'^openpyxl', output, flags=re.MULTILINE)
                 assert openpyxl_dep is None, f'openpyxl should not appear at beginning of lines in {output}'
 
-                assert '  - openpyxl==2.5.4 [requires: et-xmlfile]' in output
+                assert 'openpyxl==2.5.4 [requires: et-xmlfile]' in output
             else:
                 dep_match = re.search(fr'^[ -]*{dep_name}==[\d.]+$', output, flags=re.MULTILINE)
                 assert dep_match is not None, f'{dep_name} not found at beginning of line in {output}'
 
-            dep_requests_match = re.search(fr'^ +- tablib==0.13.0 \[requires: {dep_constraint}\]$', output, flags=re.MULTILINE)
+            dep_requests_match = re.search(fr'└── tablib==0.13.0 \[requires: {dep_constraint}', output, flags=re.MULTILINE)
             assert dep_requests_match is not None, f'constraint {dep_constraint} not found in {output}'
             assert dep_requests_match.start() > dep_match.start()