Repository: tox-dev/pipdeptree (python)

Issue:
Consider unicode line characters to visualize the tree
This package has a nice way of displaying the tree that I think could be easier to follow than the current way `pipdeptree` works using `├──` style characters:

- https://github.com/wimglenn/johnnydep

```
name                                     summary
---------------------------------------  -------------------------------------------------
boto3                                    The AWS SDK for Python
├── botocore<1.11.0,>=1.10.77            Low-level, data-driven core of boto 3.
│   ├── docutils>=0.10                   Docutils -- Python Documentation Utilities
│   ├── jmespath<1.0.0,>=0.7.1           JSON Matching Expressions
│   └── python-dateutil<3.0.0,>=2.1      Extensions to the standard Python datetime module
│       └── six>=1.5                     Python 2 and 3 compatibility utilities
├── jmespath<1.0.0,>=0.7.1               JSON Matching Expressions
└── s3transfer<0.2.0,>=0.1.10            An Amazon S3 Transfer Manager
    └── botocore<2.0.0,>=1.3.0           Low-level, data-driven core of boto 3.
        ├── docutils>=0.10               Docutils -- Python Documentation Utilities
        ├── jmespath<1.0.0,>=0.7.1       JSON Matching Expressions
        └── python-dateutil<3.0.0,>=2.1  Extensions to the standard Python datetime module
            └── six>=1.5                 Python 2 and 3 compatibility utilities
```

Pull request:
Use unicode characters when rendering text
Resolves #181.

I followed the layout mentioned in this issue as well as looked at some examples in the anytree project when implementing this.

**Note that** I did have quite a battle trying to align the bullets (e.g. ├──) to be "pointing" to the first character of a dependency name, so I used the power of trial and error to observe if each bullet aligned correctly no matter how far a dependency is down its dependency tree.

I modified the `test_render_text()` to use the Unicode characters to account for my change. I also manually tested this change by looking at projects which have large dependency trees such as Scrapy, TensorFlow, and hatch to see if they will suffer any alignment issues, and from what I was able to see it produced the expected results.

Here is an example using Scrapy:
```console
(venv) ➜  pipdeptree git:(render-text-with-unicode) ✗ pipdeptree -p Scrapy       

Scrapy==2.9.0
├── cryptography [required: >=3.4.6, installed: 41.0.1]
│   └── cffi [required: >=1.12, installed: 1.15.1]
│       └── pycparser [required: Any, installed: 2.21]
├── cssselect [required: >=0.9.1, installed: 1.2.0]
├── itemadapter [required: >=0.1.0, installed: 0.8.0]
├── itemloaders [required: >=1.0.1, installed: 1.1.0]
│   ├── itemadapter [required: >=0.1.0, installed: 0.8.0]
│   ├── jmespath [required: >=0.9.5, installed: 1.0.1]
│   ├── parsel [required: >=1.5.0, installed: 1.8.1]
│   │   ├── cssselect [required: >=0.9, installed: 1.2.0]
│   │   ├── jmespath [required: Any, installed: 1.0.1]
│   │   ├── lxml [required: Any, installed: 4.9.2]
│   │   ├── packaging [required: Any, installed: 23.1]
│   │   └── w3lib [required: >=1.19.0, installed: 2.1.1]
│   └── w3lib [required: >=1.17.0, installed: 2.1.1]
├── lxml [required: >=4.3.0, installed: 4.9.2]
├── packaging [required: Any, installed: 23.1]
├── parsel [required: >=1.5.0, installed: 1.8.1]
│   ├── cssselect [required: >=0.9, installed: 1.2.0]
│   ├── jmespath [required: Any, installed: 1.0.1]
│   ├── lxml [required: Any, installed: 4.9.2]
│   ├── packaging [required: Any, installed: 23.1]
│   └── w3lib [required: >=1.19.0, installed: 2.1.1]
├── protego [required: >=0.1.15, installed: 0.2.1]
│   └── six [required: Any, installed: 1.16.0]
├── PyDispatcher [required: >=2.0.5, installed: 2.0.7]
├── pyOpenSSL [required: >=21.0.0, installed: 23.2.0]
│   └── cryptography [required: >=38.0.0,<42,!=40.0.1,!=40.0.0, installed: 41.0.1]
│       └── cffi [required: >=1.12, installed: 1.15.1]
│           └── pycparser [required: Any, installed: 2.21]
├── queuelib [required: >=1.4.2, installed: 1.6.2]
├── service-identity [required: >=18.1.0, installed: 21.1.0]
│   ├── attrs [required: >=19.1.0, installed: 23.1.0]
│   ├── cryptography [required: Any, installed: 41.0.1]
│   │   └── cffi [required: >=1.12, installed: 1.15.1]
│   │       └── pycparser [required: Any, installed: 2.21]
│   ├── pyasn1 [required: Any, installed: 0.5.0]
│   ├── pyasn1-modules [required: Any, installed: 0.3.0]
│   │   └── pyasn1 [required: >=0.4.6,<0.6.0, installed: 0.5.0]
│   └── six [required: Any, installed: 1.16.0]
├── setuptools [required: Any, installed: 65.5.0]
├── tldextract [required: Any, installed: 3.4.4]
│   ├── filelock [required: >=3.0.8, installed: 3.12.0]
│   ├── idna [required: Any, installed: 3.4]
│   ├── requests [required: >=2.1.0, installed: 2.31.0]
│   │   ├── certifi [required: >=2017.4.17, installed: 2023.5.7]
│   │   ├── charset-normalizer [required: >=2,<4, installed: 3.1.0]
│   │   ├── idna [required: >=2.5,<4, installed: 3.4]
│   │   └── urllib3 [required: >=1.21.1,<3, installed: 1.26.16]
│   └── requests-file [required: >=1.4, installed: 1.5.1]
│       ├── requests [required: >=1.0.0, installed: 2.31.0]
│       │   ├── certifi [required: >=2017.4.17, installed: 2023.5.7]
│       │   ├── charset-normalizer [required: >=2,<4, installed: 3.1.0]
│       │   ├── idna [required: >=2.5,<4, installed: 3.4]
│       │   └── urllib3 [required: >=1.21.1,<3, installed: 1.26.16]
│       └── six [required: Any, installed: 1.16.0]
├── Twisted [required: >=18.9.0, installed: 22.10.0]
│   ├── attrs [required: >=19.2.0, installed: 23.1.0]
│   ├── Automat [required: >=0.8.0, installed: 22.10.0]
│   │   ├── attrs [required: >=19.2.0, installed: 23.1.0]
│   │   └── six [required: Any, installed: 1.16.0]
│   ├── constantly [required: >=15.1, installed: 15.1.0]
│   ├── hyperlink [required: >=17.1.1, installed: 21.0.0]
│   │   └── idna [required: >=2.5, installed: 3.4]
│   ├── incremental [required: >=21.3.0, installed: 22.10.0]
│   ├── typing-extensions [required: >=3.6.5, installed: 4.6.3]
│   └── zope.interface [required: >=4.4.2, installed: 6.0]
│       └── setuptools [required: Any, installed: 65.5.0]
├── w3lib [required: >=1.17.0, installed: 2.1.1]
└── zope.interface [required: >=5.1.0, installed: 6.0]
    └── setuptools [required: Any, installed: 65.5.0]
(venv) ➜  pipdeptree git:(render-text-with-unicode) ✗ 
```

Using the freeze option:
```console
(venv) ➜  pipdeptree git:(render-text-with-unicode) ✗ pipdeptree -p Scrapy -f

Scrapy==2.9.0
cryptography==41.0.1
  cffi==1.15.1
    pycparser==2.21
cssselect==1.2.0
itemadapter==0.8.0
itemloaders==1.1.0
  itemadapter==0.8.0
  jmespath==1.0.1
  parsel==1.8.1
    cssselect==1.2.0
    jmespath==1.0.1
    lxml==4.9.2
    packaging==23.1
    w3lib==2.1.1
  w3lib==2.1.1
lxml==4.9.2
packaging==23.1
parsel==1.8.1
  cssselect==1.2.0
  jmespath==1.0.1
  lxml==4.9.2
  packaging==23.1
  w3lib==2.1.1
Protego==0.2.1
  six==1.16.0
PyDispatcher==2.0.7
pyOpenSSL==23.2.0
  cryptography==41.0.1
    cffi==1.15.1
      pycparser==2.21
queuelib==1.6.2
service-identity==21.1.0
  attrs==23.1.0
  cryptography==41.0.1
    cffi==1.15.1
      pycparser==2.21
  pyasn1==0.5.0
  pyasn1-modules==0.3.0
    pyasn1==0.5.0
  six==1.16.0
setuptools==65.5.0
tldextract==3.4.4
  filelock==3.12.0
  idna==3.4
  requests==2.31.0
    certifi==2023.5.7
    charset-normalizer==3.1.0
    idna==3.4
    urllib3==1.26.16
  requests-file==1.5.1
    requests==2.31.0
      certifi==2023.5.7
      charset-normalizer==3.1.0
      idna==3.4
      urllib3==1.26.16
    six==1.16.0
Twisted==22.10.0
  attrs==23.1.0
  Automat==22.10.0
    attrs==23.1.0
    six==1.16.0
  constantly==15.1.0
  hyperlink==21.0.0
    idna==3.4
  incremental==22.10.0
  typing_extensions==4.6.3
  zope.interface==6.0
    setuptools==65.5.0
w3lib==2.1.1
zope.interface==6.0
  setuptools==65.5.0
(venv) ➜  pipdeptree git:(render-text-with-unicode) ✗ 
```

Patch:
diff --git a/src/pipdeptree/__init__.py b/src/pipdeptree/__init__.py
index 3c9b424..8b9fa09 100644
--- a/src/pipdeptree/__init__.py
+++ b/src/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)


Test patch:
diff --git a/tests/test_pipdeptree.py b/tests/test_pipdeptree.py
index 34fa339..473eaa3 100644
--- a/tests/test_pipdeptree.py
+++ b/tests/test_pipdeptree.py
@@ -224,12 +224,139 @@ def test_req_package_as_dict():
 # Tests for render_text
 
 
+class MockStdout:
+    """
+    A wrapper to stdout that mocks the `encoding` attribute (to have `render_text()` render with unicode/non-unicode)
+    and `write()` (so that `print()` calls can write to stdout).
+    """
+
+    def __init__(self, encoding):
+        self.stdout = sys.stdout
+        self.encoding = encoding
+
+    def encoding(self):
+        return self.encoding
+
+    def write(self, text):
+        self.stdout.write(text)
+
+
 @pytest.mark.parametrize(
-    ("list_all", "reverse", "expected_output"),
+    ("list_all", "reverse", "unicode", "expected_output"),
     [
         (
             True,
             False,
+            True,
+            [
+                "a==3.4.0",
+                "├── b [required: >=2.0.0, installed: 2.3.1]",
+                "│   └── d [required: >=2.30,<2.42, installed: 2.35]",
+                "│       └── e [required: >=0.9.0, installed: 0.12.1]",
+                "└── c [required: >=5.7.1, installed: 5.10.0]",
+                "    ├── d [required: >=2.30, installed: 2.35]",
+                "    │   └── e [required: >=0.9.0, installed: 0.12.1]",
+                "    └── e [required: >=0.12.1, installed: 0.12.1]",
+                "b==2.3.1",
+                "└── d [required: >=2.30,<2.42, installed: 2.35]",
+                "    └── e [required: >=0.9.0, installed: 0.12.1]",
+                "c==5.10.0",
+                "├── d [required: >=2.30, installed: 2.35]",
+                "│   └── e [required: >=0.9.0, installed: 0.12.1]",
+                "└── e [required: >=0.12.1, installed: 0.12.1]",
+                "d==2.35",
+                "└── e [required: >=0.9.0, installed: 0.12.1]",
+                "e==0.12.1",
+                "f==3.1",
+                "└── b [required: >=2.1.0, installed: 2.3.1]",
+                "    └── d [required: >=2.30,<2.42, installed: 2.35]",
+                "        └── e [required: >=0.9.0, installed: 0.12.1]",
+                "g==6.8.3rc1",
+                "├── e [required: >=0.9.0, installed: 0.12.1]",
+                "└── f [required: >=3.0.0, installed: 3.1]",
+                "    └── b [required: >=2.1.0, installed: 2.3.1]",
+                "        └── d [required: >=2.30,<2.42, installed: 2.35]",
+                "            └── e [required: >=0.9.0, installed: 0.12.1]",
+            ],
+        ),
+        (
+            True,
+            True,
+            True,
+            [
+                "a==3.4.0",
+                "b==2.3.1",
+                "├── a==3.4.0 [requires: b>=2.0.0]",
+                "└── f==3.1 [requires: b>=2.1.0]",
+                "    └── g==6.8.3rc1 [requires: f>=3.0.0]",
+                "c==5.10.0",
+                "└── a==3.4.0 [requires: c>=5.7.1]",
+                "d==2.35",
+                "├── b==2.3.1 [requires: d>=2.30,<2.42]",
+                "│   ├── a==3.4.0 [requires: b>=2.0.0]",
+                "│   └── f==3.1 [requires: b>=2.1.0]",
+                "│       └── g==6.8.3rc1 [requires: f>=3.0.0]",
+                "└── c==5.10.0 [requires: d>=2.30]",
+                "    └── a==3.4.0 [requires: c>=5.7.1]",
+                "e==0.12.1",
+                "├── c==5.10.0 [requires: e>=0.12.1]",
+                "│   └── a==3.4.0 [requires: c>=5.7.1]",
+                "├── d==2.35 [requires: e>=0.9.0]",
+                "│   ├── b==2.3.1 [requires: d>=2.30,<2.42]",
+                "│   │   ├── a==3.4.0 [requires: b>=2.0.0]",
+                "│   │   └── f==3.1 [requires: b>=2.1.0]",
+                "│   │       └── g==6.8.3rc1 [requires: f>=3.0.0]",
+                "│   └── c==5.10.0 [requires: d>=2.30]",
+                "│       └── a==3.4.0 [requires: c>=5.7.1]",
+                "└── g==6.8.3rc1 [requires: e>=0.9.0]",
+                "f==3.1",
+                "└── g==6.8.3rc1 [requires: f>=3.0.0]",
+                "g==6.8.3rc1",
+            ],
+        ),
+        (
+            False,
+            False,
+            True,
+            [
+                "a==3.4.0",
+                "├── b [required: >=2.0.0, installed: 2.3.1]",
+                "│   └── d [required: >=2.30,<2.42, installed: 2.35]",
+                "│       └── e [required: >=0.9.0, installed: 0.12.1]",
+                "└── c [required: >=5.7.1, installed: 5.10.0]",
+                "    ├── d [required: >=2.30, installed: 2.35]",
+                "    │   └── e [required: >=0.9.0, installed: 0.12.1]",
+                "    └── e [required: >=0.12.1, installed: 0.12.1]",
+                "g==6.8.3rc1",
+                "├── e [required: >=0.9.0, installed: 0.12.1]",
+                "└── f [required: >=3.0.0, installed: 3.1]",
+                "    └── b [required: >=2.1.0, installed: 2.3.1]",
+                "        └── d [required: >=2.30,<2.42, installed: 2.35]",
+                "            └── e [required: >=0.9.0, installed: 0.12.1]",
+            ],
+        ),
+        (
+            False,
+            True,
+            True,
+            [
+                "e==0.12.1",
+                "├── c==5.10.0 [requires: e>=0.12.1]",
+                "│   └── a==3.4.0 [requires: c>=5.7.1]",
+                "├── d==2.35 [requires: e>=0.9.0]",
+                "│   ├── b==2.3.1 [requires: d>=2.30,<2.42]",
+                "│   │   ├── a==3.4.0 [requires: b>=2.0.0]",
+                "│   │   └── f==3.1 [requires: b>=2.1.0]",
+                "│   │       └── g==6.8.3rc1 [requires: f>=3.0.0]",
+                "│   └── c==5.10.0 [requires: d>=2.30]",
+                "│       └── a==3.4.0 [requires: c>=5.7.1]",
+                "└── g==6.8.3rc1 [requires: e>=0.9.0]",
+            ],
+        ),
+        (
+            True,
+            False,
+            False,
             [
                 "a==3.4.0",
                 "  - b [required: >=2.0.0, installed: 2.3.1]",
@@ -264,6 +391,7 @@ def test_req_package_as_dict():
         (
             True,
             True,
+            False,
             [
                 "a==3.4.0",
                 "b==2.3.1",
@@ -296,6 +424,7 @@ def test_req_package_as_dict():
             ],
         ),
         (
+            False,
             False,
             False,
             [
@@ -318,6 +447,7 @@ def test_req_package_as_dict():
         (
             False,
             True,
+            False,
             [
                 "e==0.12.1",
                 "  - c==5.10.0 [requires: e>=0.12.1]",
@@ -334,11 +464,13 @@ def test_req_package_as_dict():
         ),
     ],
 )
-def test_render_text(capsys, list_all, reverse, expected_output):
+def test_render_text(capsys, list_all, reverse, unicode, expected_output):
     tree = t.reverse() if reverse else t
-    p.render_text(tree, list_all=list_all, frozen=False)
-    captured = capsys.readouterr()
-    assert "\n".join(expected_output).strip() == captured.out.strip()
+    encoding = "utf-8" if unicode else "ascii"
+    with mock.patch("sys.stdout", MockStdout(encoding)):
+        p.render_text(tree, list_all=list_all, frozen=False)
+        captured = capsys.readouterr()
+        assert "\n".join(expected_output).strip() == captured.out.strip()
 
 
 # Tests for graph outputs