Repository: astanin/python-tabulate

Issue:
Support hyperlinks encoded as terminal escape sequences
Some terminals support [clickable text](https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda) via escape sequences. If we use the escape sequence

```python
>>> import tabulate
>>>
>>> def encode_url(text: str, target:str):
...     return f"\u001b]8;;{target}\u001b\\{text}\u001b]8;;\u001b\\"
... 
>>> table = tabulate.tabulate([[encode_url("test", "http://example.org")]], headers=["test"], tablefmt="grid")
>>> print(table)
+--------+
| test   |
+========+
| test        |
+--------+
```

tabulate does not calculate the width correctly.

Comments:

Patch:
diff --git a/.gitignore b/.gitignore
index 1579287..460933e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,16 @@ dist
 *~
 *.pyc
 /tabulate.egg-info/
+*.egg*
+*.pyc
+.*
+build/
+.coverage
+coverage.xml
+dist/
+doc/changelog.rst
+venv*
+website-build/
+## Unit test / coverage reports
+.coverage
+.tox
diff --git a/README.md b/README.md
index ce06dad..7125559 100644
--- a/README.md
+++ b/README.md
@@ -176,7 +176,7 @@ extensions](http://johnmacfarlane.net/pandoc/README.html#tables):
     eggs      451
     bacon       0
 
-`github` follows the conventions of Github flavored Markdown. It
+`github` follows the conventions of GitHub flavored Markdown. It
 corresponds to the `pipe` format without alignment colons:
 
     >>> print(tabulate(table, headers, tablefmt="github"))
@@ -668,9 +668,9 @@ as a number imply that `tabulate`:
 It may not be suitable for serializing really big tables (but who's
 going to do that, anyway?) or printing tables in performance sensitive
 applications. `tabulate` is about two orders of magnitude slower than
-simply joining lists of values with a tab, coma or other separator.
+simply joining lists of values with a tab, comma, or other separator.
 
-In the same time `tabulate` is comparable to other table
+At the same time, `tabulate` is comparable to other table
 pretty-printers. Given a 10x10 table (a list of lists) of mixed text and
 numeric data, `tabulate` appears to be slower than `asciitable`, and
 faster than `PrettyTable` and `texttable` The following mini-benchmark
@@ -714,7 +714,7 @@ On Linux `tox` expects to find executables like `python2.6`,
 `C:\Python26\python.exe`, `C:\Python27\python.exe` and
 `C:\Python34\python.exe` respectively.
 
-To test only some Python environements, use `-e` option. For example, to
+To test only some Python environments, use `-e` option. For example, to
 test only against Python 2.7 and Python 3.6, run:
 
     tox -e py27,py36
diff --git a/tabulate.py b/tabulate.py
index 5d57167..65abfd6 100755
--- a/tabulate.py
+++ b/tabulate.py
@@ -5,17 +5,17 @@
 from __future__ import print_function
 from __future__ import unicode_literals
 from collections import namedtuple
-from platform import python_version_tuple
+import sys
 import re
 import math
 
 
-if python_version_tuple() >= ("3", "3", "0"):
+if sys.version_info >= (3, 3):
     from collections.abc import Iterable
 else:
     from collections import Iterable
 
-if python_version_tuple()[0] < "3":
+if sys.version_info[0] < 3:
     from itertools import izip_longest
     from functools import partial
 
@@ -91,9 +91,9 @@ DataRow = namedtuple("DataRow", ["begin", "sep", "end"])
 #         headerrow
 #     --- linebelowheader ---
 #         datarow
-#     --- linebewteenrows ---
+#     --- linebetweenrows ---
 #     ... (more datarows) ...
-#     --- linebewteenrows ---
+#     --- linebetweenrows ---
 #         last datarow
 #     --- linebelow ---------
 #
@@ -535,11 +535,14 @@ multiline_formats = {
 _multiline_codes = re.compile(r"\r|\n|\r\n")
 _multiline_codes_bytes = re.compile(b"\r|\n|\r\n")
 _invisible_codes = re.compile(
-    r"\x1b\[\d+[;\d]*m|\x1b\[\d*\;\d*\;\d*m"
+    r"\x1b\[\d+[;\d]*m|\x1b\[\d*\;\d*\;\d*m|\x1b\]8;;(.*?)\x1b\\"
 )  # ANSI color codes
 _invisible_codes_bytes = re.compile(
-    b"\x1b\\[\\d+\\[;\\d]*m|\x1b\\[\\d*;\\d*;\\d*m"
+    b"\x1b\\[\\d+\\[;\\d]*m|\x1b\\[\\d*;\\d*;\\d*m|\\x1b\\]8;;(.*?)\\x1b\\\\"
 )  # ANSI color codes
+_invisible_codes_link = re.compile(
+    r"\x1B]8;[a-zA-Z0-9:]*;[^\x1B]+\x1B\\([^\x1b]+)\x1B]8;;\x1B\\"
+)  # Terminal hyperlinks
 
 
 def simple_separated_format(separator):
@@ -724,9 +727,15 @@ def _padnone(ignore_width, s):
 
 
 def _strip_invisible(s):
-    "Remove invisible ANSI color codes."
+    r"""Remove invisible ANSI color codes.
+
+    >>> str(_strip_invisible('\x1B]8;;https://example.com\x1B\\This is a link\x1B]8;;\x1B\\'))
+    'This is a link'
+
+    """
     if isinstance(s, _text_type):
-        return re.sub(_invisible_codes, "", s)
+        links_removed = re.sub(_invisible_codes_link, "\\1", s)
+        return re.sub(_invisible_codes, "", links_removed)
     else:  # a bytestring
         return re.sub(_invisible_codes_bytes, "", s)
 
@@ -894,7 +903,7 @@ def _column_type(strings, has_invisible=True, numparse=True):
 
 
 def _format(val, valtype, floatfmt, missingval="", has_invisible=True):
-    """Format a value accoding to its type.
+    """Format a value according to its type.
 
     Unicode is supported:
 
@@ -1022,7 +1031,10 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"):
         elif hasattr(tabular_data, "index"):
             # values is a property, has .index => it's likely a pandas.DataFrame (pandas 0.11.0)
             keys = list(tabular_data)
-            if tabular_data.index.name is not None:
+            if (
+                showindex in ["default", "always", True]
+                and tabular_data.index.name is not None
+            ):
                 if isinstance(tabular_data.index.name, list):
                     keys[:0] = tabular_data.index.name
                 else:
@@ -1469,6 +1481,8 @@ def tabulate(
     )
 
     has_invisible = re.search(_invisible_codes, plain_text)
+    if not has_invisible:
+        has_invisible = re.search(_invisible_codes_link, plain_text)
     enable_widechars = wcwidth is not None and WIDE_CHARS_MODE
     if (
         not isinstance(tablefmt, TableFormat)
diff --git a/tox.ini b/tox.ini
index c9f4e98..a20b325 100644
--- a/tox.ini
+++ b/tox.ini
@@ -8,7 +8,7 @@
 # for testing and it is disabled by default.
 
 [tox]
-envlist = lint, py27, py35, py36, py37, py38
+envlist = lint, py27, py35, py36, py37, py38, py39, py310
 
 [testenv]
 commands = pytest -v --doctest-modules --ignore benchmark.py
@@ -97,6 +97,40 @@ deps =
     pandas
     wcwidth
 
+
+[testenv:py39]
+basepython = python3.9
+commands = pytest -v --doctest-modules --ignore benchmark.py
+deps =
+    pytest
+
+[testenv:py39-extra]
+basepython = python3.9
+commands = pytest -v --doctest-modules --ignore benchmark.py
+deps =
+    pytest
+    numpy
+    pandas
+    wcwidth
+
+
+[testenv:py310]
+basepython = python3.10
+commands = pytest -v --doctest-modules --ignore benchmark.py
+deps =
+    pytest
+
+[testenv:py310-extra]
+basepython = python3.10
+setenv = PYTHONDEVMODE = 1
+commands = pytest -v --doctest-modules --ignore benchmark.py
+deps =
+    pytest
+    numpy
+    pandas
+    wcwidth
+
+
 [flake8]
 max-complexity = 22
 max-line-length = 99


Test patch:
diff --git a/test/test_output.py b/test/test_output.py
index 0e72c71..abab439 100644
--- a/test/test_output.py
+++ b/test/test_output.py
@@ -59,6 +59,22 @@ def test_plain_multiline():
     assert_equal(expected, result)
 
 
+def test_plain_multiline_with_links():
+    "Output: plain with multiline cells with links and headers"
+    table = [[2, "foo\nbar"]]
+    headers = ("more\nspam \x1b]8;;target\x1b\\eggs\x1b]8;;\x1b\\", "more spam\n& eggs")
+    expected = "\n".join(
+        [
+            "       more  more spam",
+            "  spam \x1b]8;;target\x1b\\eggs\x1b]8;;\x1b\\  & eggs",
+            "          2  foo",
+            "             bar",
+        ]
+    )
+    result = tabulate(table, headers, tablefmt="plain")
+    assert_equal(expected, result)
+
+
 def test_plain_multiline_with_empty_cells():
     "Output: plain with multiline cells and empty cells with headers"
     table = [
@@ -162,6 +178,23 @@ def test_simple_multiline():
     assert_equal(expected, result)
 
 
+def test_simple_multiline_with_links():
+    "Output: simple with multiline cells with links and headers"
+    table = [[2, "foo\nbar"]]
+    headers = ("more\nspam \x1b]8;;target\x1b\\eggs\x1b]8;;\x1b\\", "more spam\n& eggs")
+    expected = "\n".join(
+        [
+            "       more  more spam",
+            "  spam \x1b]8;;target\x1b\\eggs\x1b]8;;\x1b\\  & eggs",
+            "-----------  -----------",
+            "          2  foo",
+            "             bar",
+        ]
+    )
+    result = tabulate(table, headers, tablefmt="simple")
+    assert_equal(expected, result)
+
+
 def test_simple_multiline_with_empty_cells():
     "Output: simple with multiline cells and empty cells with headers"
     table = [
@@ -766,6 +799,25 @@ def test_pretty_multiline():
     assert_equal(expected, result)
 
 
+def test_pretty_multiline_with_links():
+    "Output: pretty with multiline cells with headers"
+    table = [[2, "foo\nbar"]]
+    headers = ("more\nspam \x1b]8;;target\x1b\\eggs\x1b]8;;\x1b\\", "more spam\n& eggs")
+    expected = "\n".join(
+        [
+            "+-----------+-----------+",
+            "|   more    | more spam |",
+            "| spam \x1b]8;;target\x1b\\eggs\x1b]8;;\x1b\\ |  & eggs   |",
+            "+-----------+-----------+",
+            "|     2     |    foo    |",
+            "|           |    bar    |",
+            "+-----------+-----------+",
+        ]
+    )
+    result = tabulate(table, headers, tablefmt="pretty")
+    assert_equal(expected, result)
+
+
 def test_pretty_multiline_with_empty_cells():
     "Output: pretty with multiline cells and empty cells with headers"
     table = [
@@ -889,6 +941,25 @@ def test_rst_multiline():
     assert_equal(expected, result)
 
 
+def test_rst_multiline_with_links():
+    "Output: rst with multiline cells with headers"
+    table = [[2, "foo\nbar"]]
+    headers = ("more\nspam \x1b]8;;target\x1b\\eggs\x1b]8;;\x1b\\", "more spam\n& eggs")
+    expected = "\n".join(
+        [
+            "===========  ===========",
+            "       more  more spam",
+            "  spam \x1b]8;;target\x1b\\eggs\x1b]8;;\x1b\\  & eggs",
+            "===========  ===========",
+            "          2  foo",
+            "             bar",
+            "===========  ===========",
+        ]
+    )
+    result = tabulate(table, headers, tablefmt="rst")
+    assert_equal(expected, result)
+
+
 def test_rst_multiline_with_empty_cells():
     "Output: rst with multiline cells and empty cells with headers"
     table = [
@@ -1363,7 +1434,9 @@ def test_pandas_without_index():
         import pandas
 
         df = pandas.DataFrame(
-            [["one", 1], ["two", None]], columns=["string", "number"], index=["a", "b"]
+            [["one", 1], ["two", None]],
+            columns=["string", "number"],
+            index=pandas.Index(["a", "b"], name="index"),
         )
         expected = "\n".join(
             [
diff --git a/test/test_regression.py b/test/test_regression.py
index 2324c06..955e11f 100644
--- a/test/test_regression.py
+++ b/test/test_regression.py
@@ -47,6 +47,52 @@ def test_alignment_of_colored_cells():
     assert_equal(expected, formatted)
 
 
+def test_alignment_of_link_cells():
+    "Regression: Align links as if they were colorless."
+    linktable = [
+        ("test", 42, "\x1b]8;;target\x1b\\test\x1b]8;;\x1b\\"),
+        ("test", 101, "\x1b]8;;target\x1b\\test\x1b]8;;\x1b\\"),
+    ]
+    linkheaders = ("test", "\x1b]8;;target\x1b\\test\x1b]8;;\x1b\\", "test")
+    formatted = tabulate(linktable, linkheaders, "grid")
+    expected = "\n".join(
+        [
+            "+--------+--------+--------+",
+            "| test   |   \x1b]8;;target\x1b\\test\x1b]8;;\x1b\\ | test   |",
+            "+========+========+========+",
+            "| test   |     42 | \x1b]8;;target\x1b\\test\x1b]8;;\x1b\\   |",
+            "+--------+--------+--------+",
+            "| test   |    101 | \x1b]8;;target\x1b\\test\x1b]8;;\x1b\\   |",
+            "+--------+--------+--------+",
+        ]
+    )
+    print("expected: %r\n\ngot:      %r\n" % (expected, formatted))
+    assert_equal(expected, formatted)
+
+
+def test_alignment_of_link_text_cells():
+    "Regression: Align links as if they were colorless."
+    linktable = [
+        ("test", 42, "1\x1b]8;;target\x1b\\test\x1b]8;;\x1b\\2"),
+        ("test", 101, "3\x1b]8;;target\x1b\\test\x1b]8;;\x1b\\4"),
+    ]
+    linkheaders = ("test", "5\x1b]8;;target\x1b\\test\x1b]8;;\x1b\\6", "test")
+    formatted = tabulate(linktable, linkheaders, "grid")
+    expected = "\n".join(
+        [
+            "+--------+----------+--------+",
+            "| test   |   5\x1b]8;;target\x1b\\test\x1b]8;;\x1b\\6 | test   |",
+            "+========+==========+========+",
+            "| test   |       42 | 1\x1b]8;;target\x1b\\test\x1b]8;;\x1b\\2 |",
+            "+--------+----------+--------+",
+            "| test   |      101 | 3\x1b]8;;target\x1b\\test\x1b]8;;\x1b\\4 |",
+            "+--------+----------+--------+",
+        ]
+    )
+    print("expected: %r\n\ngot:      %r\n" % (expected, formatted))
+    assert_equal(expected, formatted)
+
+
 def test_iter_of_iters_with_headers():
     "Regression: Generator of generators with a gen. of headers (issue #9)."