Repository: astanin/python-tabulate
Pull request #47 (2020-03-28 17:37:37)

benchmark: remove redundant assignment and double import
Changes in `benchmark.py`.

Assigning `methods = methods` is redundant.
Module `platform` was imported with both `import` and `import from`, merged into one `import from`.


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/CHANGELOG b/CHANGELOG
index 4a489ca..47f1d1e 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,4 +1,10 @@
-- 0.8.8: Future version.
+- 0.8.8: Python 3.9 support, 3.10 ready.
+  New formats: ``unsafehtml``, ``latex_longtable``, ``fancy_outline``.
+  Support lists of UserDicts as input.
+  Support hyperlinks in terminal output.
+  Improve testing on systems with proxies.
+  Migrate to pytest.
+  Various bug fixes and improvements.
 - 0.8.7: Bug fixes. New format: `pretty`. HTML escaping.
 - 0.8.6: Bug fixes. Stop supporting Python 3.3, 3.4.
 - 0.8.5: Fix broken Windows package. Minor documentation updates.
diff --git a/README b/README
index b43bf86..42061c0 120000
--- a/README
+++ b/README
@@ -1,1 +1,1 @@
-README.md
+README.md
\ No newline at end of file
diff --git a/README.md b/README.md
index a4f9881..b17ab5a 100644
--- a/README.md
+++ b/README.md
@@ -149,10 +149,13 @@ Supported table formats are:
 -   "moinmoin"
 -   "youtrack"
 -   "html"
+-   "unsafehtml"
 -   "latex"
 -   "latex\_raw"
 -   "latex\_booktabs"
+-   "latex\_longtable"
 -   "textile"
+-   "tsv"
 
 `plain` tables do not use any pseudo-graphics to draw lines:
 
@@ -175,7 +178,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"))
@@ -334,7 +337,8 @@ MediaWiki-based sites:
 
 `html` produces standard HTML markup as an html.escape'd str
 with a ._repr_html_ method so that Jupyter Lab and Notebook display the HTML
-and a .str property so that the raw HTML remains accessible:
+and a .str property so that the raw HTML remains accessible.
+`unsafehtml` table format can be used if an unescaped HTML is required:
 
     >>> print(tabulate(table, headers, tablefmt="html"))
     <table>
@@ -367,6 +371,9 @@ special characters.
 `latex_booktabs` creates a `tabular` environment for LaTeX markup using
 spacing and style from the `booktabs` package.
 
+`latex_longtable` creates a table that can stretch along multiple pages,
+using the `longtable` package.
+
 ### Column alignment
 
 `tabulate` is smart about column alignment. It detects columns which
@@ -649,7 +656,7 @@ Usage of the command line utility
     -f FMT, --format FMT      set output table format; supported formats:
                               plain, simple, github, grid, fancy_grid, pipe,
                               orgtbl, rst, mediawiki, html, latex, latex_raw,
-                              latex_booktabs, tsv
+                              latex_booktabs, latex_longtable, tsv
                               (default: simple)
 
 Performance considerations
@@ -666,24 +673,25 @@ 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
 was run in Python 3.8.1 in Windows 10 x64:
 
-    ===========================  ==========  ===========
-    Table formatter                time, μs    rel. time
-    ===========================  ==========  ===========
-    csv to StringIO                    12.4          1.0
-    join with tabs and newlines        15.7          1.3
-    asciitable (0.8.0)                208.3         16.7
-    tabulate (0.8.7)                  492.1         39.5
-    PrettyTable (0.7.2)               945.5         76.0
-    texttable (1.6.2)                1239.5         99.6
-    ===========================  ==========  ===========
+    =================================  ==========  ===========
+    Table formatter                      time, μs    rel. time
+    =================================  ==========  ===========
+    csv to StringIO                          12.0          1.0
+    join with tabs and newlines              14.7          1.2
+    asciitable (0.8.0)                      189.6         15.8
+    tabulate (0.8.8)                        480.9         40.1
+    tabulate (0.8.8, WIDE_CHARS_MODE)       610.2         50.8
+    PrettyTable (2.0.0)                     899.8         75.0
+    texttable (1.6.3)                      1117.3         93.1
+    =================================  ==========  ===========
 
 
 Version history
@@ -698,13 +706,13 @@ Contributions should include tests and an explanation for the changes
 they propose. Documentation (examples, docstrings, README.md) should be
 updated accordingly.
 
-This project uses [nose](https://nose.readthedocs.org/) testing
+This project uses [pytest](https://docs.pytest.org/) testing
 framework and [tox](https://tox.readthedocs.io/) to automate testing in
 different environments. Add tests to one of the files in the `test/`
 folder.
 
 To run tests on all supported Python versions, make sure all Python
-interpreters, `nose` and `tox` are installed, then run `tox` in the root
+interpreters, `pytest` and `tox` are installed, then run `tox` in the root
 of the project source tree.
 
 On Linux `tox` expects to find executables like `python2.6`,
@@ -712,21 +720,25 @@ 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
-test only against Python 2.7 and Python 3.6, run:
+To test only some Python environments, use `-e` option. For example, to
+test only against Python 2.7 and Python 3.8, run:
 
-    tox -e py27,py36
+    tox -e py27,py38
 
 in the root of the project source tree.
 
 To enable NumPy and Pandas tests, run:
 
-    tox -e py27-extra,py36-extra
+    tox -e py27-extra,py38-extra
 
 (this may take a long time the first time, because NumPy and Pandas will
 have to be installed in the new virtual environments)
 
-See `tox.ini` file to learn how to use `nosetests` directly to test
+To fix code formatting:
+
+    tox -e lint
+
+See `tox.ini` file to learn how to use to test
 individual Python versions.
 
 Contributors
@@ -742,4 +754,8 @@ Maier, Andy MacKinlay, Thomas Roten, Jue Wang, Joe King, Samuel Phan,
 Nick Satterly, Daniel Robbins, Dmitry B, Lars Butler, Andreas Maier,
 Dick Marinus, Sébastien Celles, Yago González, Andrew Gaul, Wim Glenn,
 Jean Michel Rouly, Tim Gates, John Vandenberg, Sorin Sbarnea,
-Wes Turner, Andrew Tija, Marco Gorelli, Sean McGinnis.
+Wes Turner, Andrew Tija, Marco Gorelli, Sean McGinnis, danja100,
+endolith, Dominic Davis-Foster, pavlocat, Daniel Aslau, paulc,
+Felix Yan, Shane Loretz, Frank Busse, Harsh Singh, Derek Weitzel,
+Vladimir Vrzić, 서승우 (chrd5273), Georgy Frolov, Christian Cwienk,
+Bart Broere.
\ No newline at end of file
diff --git a/benchmark.py b/benchmark.py
index d964e15..b79c47b 100644
--- a/benchmark.py
+++ b/benchmark.py
@@ -96,8 +96,6 @@ def benchmark(n):
     global methods
     if "--onlyself" in sys.argv[1:]:
         methods = [m for m in methods if m[0].startswith("tabulate")]
-    else:
-        methods = methods
 
     results = [
         (desc, timeit(code, setup_code, number=n) / n * 1e6) for desc, code in methods
@@ -110,9 +108,9 @@ def benchmark(n):
         results, ["Table formatter", "time, μs", "rel. time"], "rst", floatfmt=".1f"
     )
 
-    import platform
+    from platform import platform
 
-    if platform.platform().startswith("Windows"):
+    if platform().startswith("Windows"):
         print(table)
     elif python_version_tuple()[0] < "3":
         print(codecs.encode(table, "utf-8"))
diff --git a/setup.py b/setup.py
index ee9fcec..75c3e45 100644
--- a/setup.py
+++ b/setup.py
@@ -38,7 +38,7 @@ else:
 
 setup(
     name="tabulate",
-    version="0.8.8",
+    version="0.8.9",
     description="Pretty-print tabular data",
     long_description=LONG_DESCRIPTION,
     long_description_content_type="text/markdown",
@@ -57,6 +57,7 @@ setup(
         "Programming Language :: Python :: 3.6",
         "Programming Language :: Python :: 3.7",
         "Programming Language :: Python :: 3.8",
+        "Programming Language :: Python :: 3.9",
         "Topic :: Software Development :: Libraries",
     ],
     py_modules=["tabulate"],
diff --git a/tabulate.py b/tabulate.py
old mode 100755
new mode 100644
index b73bbe0..7d49311
--- 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
 
@@ -62,7 +62,7 @@ except ImportError:
 
 
 __all__ = ["tabulate", "tabulate_formats", "simple_separated_format"]
-__version__ = "0.8.8"
+__version__ = "0.8.9"
 
 
 # minimum extra space in headers
@@ -73,6 +73,9 @@ PRESERVE_WHITESPACE = False
 
 _DEFAULT_FLOATFMT = "g"
 _DEFAULT_MISSINGVAL = ""
+# default align will be overwritten by "left", "center" or "decimal"
+# depending on the formatter
+_DEFAULT_ALIGN = "default"
 
 
 # if True, enable wide-character (CJK) support
@@ -91,9 +94,9 @@ DataRow = namedtuple("DataRow", ["begin", "sep", "end"])
 #         headerrow
 #     --- linebelowheader ---
 #         datarow
-#     --- linebewteenrows ---
+#     --- linebetweenrows ---
 #     ... (more datarows) ...
-#     --- linebewteenrows ---
+#     --- linebetweenrows ---
 #         last datarow
 #     --- linebelow ---------
 #
@@ -182,17 +185,23 @@ def _html_begin_table_without_header(colwidths_ignore, colaligns_ignore):
     return "<table>\n<tbody>"
 
 
-def _html_row_with_attrs(celltag, cell_values, colwidths, colaligns):
+def _html_row_with_attrs(celltag, unsafe, cell_values, colwidths, colaligns):
     alignment = {
         "left": "",
         "right": ' style="text-align: right;"',
         "center": ' style="text-align: center;"',
         "decimal": ' style="text-align: right;"',
     }
-    values_with_attrs = [
-        "<{0}{1}>{2}</{0}>".format(celltag, alignment.get(a, ""), htmlescape(c))
-        for c, a in zip(cell_values, colaligns)
-    ]
+    if unsafe:
+        values_with_attrs = [
+            "<{0}{1}>{2}</{0}>".format(celltag, alignment.get(a, ""), c)
+            for c, a in zip(cell_values, colaligns)
+        ]
+    else:
+        values_with_attrs = [
+            "<{0}{1}>{2}</{0}>".format(celltag, alignment.get(a, ""), htmlescape(c))
+            for c, a in zip(cell_values, colaligns)
+        ]
     rowhtml = "<tr>{}</tr>".format("".join(values_with_attrs).rstrip())
     if celltag == "th":  # it's a header row, create a new table header
         rowhtml = "<table>\n<thead>\n{}\n</thead>\n<tbody>".format(rowhtml)
@@ -213,12 +222,14 @@ def _moin_row_with_attrs(celltag, cell_values, colwidths, colaligns, header=""):
     return "".join(values_with_attrs) + "||"
 
 
-def _latex_line_begin_tabular(colwidths, colaligns, booktabs=False):
+def _latex_line_begin_tabular(colwidths, colaligns, booktabs=False, longtable=False):
     alignment = {"left": "l", "right": "r", "center": "c", "decimal": "r"}
     tabular_columns_fmt = "".join([alignment.get(a, "l") for a in colaligns])
     return "\n".join(
         [
-            "\\begin{tabular}{" + tabular_columns_fmt + "}",
+            ("\\begin{tabular}{" if not longtable else "\\begin{longtable}{")
+            + tabular_columns_fmt
+            + "}",
             "\\toprule" if booktabs else "\\hline",
         ]
     )
@@ -309,6 +320,16 @@ _table_formats = {
         padding=1,
         with_header_hide=None,
     ),
+    "fancy_outline": TableFormat(
+        lineabove=Line("╒", "═", "╤", "╕"),
+        linebelowheader=Line("╞", "═", "╪", "╡"),
+        linebetweenrows=None,
+        linebelow=Line("╘", "═", "╧", "╛"),
+        headerrow=DataRow("│", "│", "│"),
+        datarow=DataRow("│", "│", "│"),
+        padding=1,
+        with_header_hide=None,
+    ),
     "github": TableFormat(
         lineabove=Line("|", "-", "|", "|"),
         linebelowheader=Line("|", "-", "|", "|"),
@@ -429,8 +450,18 @@ _table_formats = {
         linebelowheader="",
         linebetweenrows=None,
         linebelow=Line("</tbody>\n</table>", "", "", ""),
-        headerrow=partial(_html_row_with_attrs, "th"),
-        datarow=partial(_html_row_with_attrs, "td"),
+        headerrow=partial(_html_row_with_attrs, "th", False),
+        datarow=partial(_html_row_with_attrs, "td", False),
+        padding=0,
+        with_header_hide=["lineabove"],
+    ),
+    "unsafehtml": TableFormat(
+        lineabove=_html_begin_table_without_header,
+        linebelowheader="",
+        linebetweenrows=None,
+        linebelow=Line("</tbody>\n</table>", "", "", ""),
+        headerrow=partial(_html_row_with_attrs, "th", True),
+        datarow=partial(_html_row_with_attrs, "td", True),
         padding=0,
         with_header_hide=["lineabove"],
     ),
@@ -464,6 +495,16 @@ _table_formats = {
         padding=1,
         with_header_hide=None,
     ),
+    "latex_longtable": TableFormat(
+        lineabove=partial(_latex_line_begin_tabular, longtable=True),
+        linebelowheader=Line("\\hline\n\\endhead", "", "", ""),
+        linebetweenrows=None,
+        linebelow=Line("\\hline\n\\end{longtable}", "", "", ""),
+        headerrow=_latex_row,
+        datarow=_latex_row,
+        padding=1,
+        with_header_hide=None,
+    ),
     "tsv": TableFormat(
         lineabove=None,
         linebelowheader=None,
@@ -519,11 +560,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):
@@ -566,8 +610,16 @@ def _isnumber(string):
     False
     >>> _isnumber("inf")
     True
+    >>> _isnumber("1,234.56")
+    True
+    >>> _isnumber("1,234,578.0")
+    True
     """
-    if not _isconvertible(float, string):
+    if isinstance(string, (_text_type)) and "," in string and (
+        _isconvertible(float, string.replace(",",""))
+    ):
+        return True
+    elif not _isconvertible(float, string):
         return False
     elif isinstance(string, (_text_type, _binary_type)) and (
         math.isinf(float(string)) or math.isnan(float(string))
@@ -708,9 +760,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)
 
@@ -786,6 +844,36 @@ def _align_column_choose_padfn(strings, alignment, has_invisible):
     return strings, padfn
 
 
+def _align_column_choose_width_fn(has_invisible, enable_widechars, is_multiline):
+    if has_invisible:
+        line_width_fn = _visible_width
+    elif enable_widechars:  # optional wide-character support if available
+        line_width_fn = wcwidth.wcswidth
+    else:
+        line_width_fn = len
+    if is_multiline:
+        width_fn = lambda s: _align_column_multiline_width(s, line_width_fn)  # noqa
+    else:
+        width_fn = line_width_fn
+    return width_fn
+
+
+def _align_column_multiline_width(multiline_s, line_width_fn=len):
+    """Visible width of a potentially multiline content."""
+    return list(map(line_width_fn, re.split("[\r\n]", multiline_s)))
+
+
+def _flat_list(nested_list):
+    ret = []
+    for item in nested_list:
+        if isinstance(item, list):
+            for subitem in item:
+                ret.append(subitem)
+        else:
+            ret.append(item)
+    return ret
+
+
 def _align_column(
     strings,
     alignment,
@@ -796,10 +884,12 @@ def _align_column(
 ):
     """[string] -> [padded_string]"""
     strings, padfn = _align_column_choose_padfn(strings, alignment, has_invisible)
-    width_fn = _choose_width_fn(has_invisible, enable_widechars, is_multiline)
+    width_fn = _align_column_choose_width_fn(
+        has_invisible, enable_widechars, is_multiline
+    )
 
     s_widths = list(map(width_fn, strings))
-    maxwidth = max(max(s_widths), minwidth)
+    maxwidth = max(max(_flat_list(s_widths)), minwidth)
     # TODO: refactor column alignment in single-line and multiline modes
     if is_multiline:
         if not enable_widechars and not has_invisible:
@@ -809,13 +899,16 @@ def _align_column(
             ]
         else:
             # enable wide-character width corrections
-            s_lens = [max((len(s) for s in re.split("[\r\n]", ms))) for ms in strings]
-            visible_widths = [maxwidth - (w - l) for w, l in zip(s_widths, s_lens)]
+            s_lens = [[len(s) for s in re.split("[\r\n]", ms)] for ms in strings]
+            visible_widths = [
+                [maxwidth - (w - l) for w, l in zip(mw, ml)]
+                for mw, ml in zip(s_widths, s_lens)
+            ]
             # wcswidth and _visible_width don't count invisible characters;
             # padfn doesn't need to apply another correction
             padded_strings = [
-                "\n".join([padfn(w, s) for s in (ms.splitlines() or ms)])
-                for ms, w in zip(strings, visible_widths)
+                "\n".join([padfn(w, s) for s, w in zip((ms.splitlines() or ms), mw)])
+                for ms, mw in zip(strings, visible_widths)
             ]
     else:  # single-line cell values
         if not enable_widechars and not has_invisible:
@@ -878,7 +971,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:
 
@@ -1006,7 +1099,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:
@@ -1042,8 +1138,8 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"):
         ):
             # namedtuple
             headers = list(map(_text_type, rows[0]._fields))
-        elif len(rows) > 0 and isinstance(rows[0], dict):
-            # dict or OrderedDict
+        elif len(rows) > 0 and hasattr(rows[0], "keys") and hasattr(rows[0], "values"):
+            # dict-like object
             uniq_keys = set()  # implements hashed lookup
             keys = []  # storage for set
             if headers == "firstrow":
@@ -1130,8 +1226,8 @@ def tabulate(
     headers=(),
     tablefmt="simple",
     floatfmt=_DEFAULT_FLOATFMT,
-    numalign="decimal",
-    stralign="left",
+    numalign=_DEFAULT_ALIGN,
+    stralign=_DEFAULT_ALIGN,
     missingval=_DEFAULT_MISSINGVAL,
     showindex="default",
     disable_numparse=False,
@@ -1221,8 +1317,8 @@ def tabulate(
 
     Various plain-text table formats (`tablefmt`) are supported:
     'plain', 'simple', 'grid', 'pipe', 'orgtbl', 'rst', 'mediawiki',
-    'latex', 'latex_raw' and 'latex_booktabs'. Variable `tabulate_formats`
-    contains the list of currently supported formats.
+    'latex', 'latex_raw', 'latex_booktabs', 'latex_longtable' and tsv.
+    Variable `tabulate_formats`contains the list of currently supported formats.
 
     "plain" format doesn't use any pseudographics to draw tables,
     it separates columns with a double space:
@@ -1360,7 +1456,8 @@ def tabulate(
 
     "html" produces HTML markup as an html.escape'd str
     with a ._repr_html_ method so that Jupyter Lab and Notebook display the HTML
-    and a .str property so that the raw HTML remains accessible:
+    and a .str property so that the raw HTML remains accessible
+    the unsafehtml table format can be used if an unescaped HTML format is required:
 
     >>> print(tabulate([["strings", "numbers"], ["spam", 41.9999], ["eggs", "451.0"]],
     ...                headers="firstrow", tablefmt="html"))
@@ -1407,6 +1504,18 @@ def tabulate(
     \\bottomrule
     \\end{tabular}
 
+    "latex_longtable" produces a tabular environment that can stretch along
+    multiple pages, using the longtable package for LaTeX.
+
+    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="latex_longtable"))
+    \\begin{longtable}{lr}
+    \\hline
+     spam &  41.9999 \\\\
+     eggs & 451      \\\\
+    \\hline
+    \\end{longtable}
+
+
     Number parsing
     --------------
     By default, anything which can be parsed as a number is a number.
@@ -1421,6 +1530,7 @@ def tabulate(
     e.g. `disable_numparse=[0, 2]` would disable number parsing only on the
     first and third columns.
     """
+
     if tabular_data is None:
         tabular_data = []
     list_of_lists, headers = _normalize_tabular_data(
@@ -1440,8 +1550,11 @@ def tabulate(
     if tablefmt == "pretty":
         min_padding = 0
         disable_numparse = True
-        numalign = "center"
-        stralign = "center"
+        numalign = "center" if numalign == _DEFAULT_ALIGN else numalign
+        stralign = "center" if stralign == _DEFAULT_ALIGN else stralign
+    else:
+        numalign = "decimal" if numalign == _DEFAULT_ALIGN else numalign
+        stralign = "left" if stralign == _DEFAULT_ALIGN else stralign
 
     # optimization: look for ANSI control codes once,
     # enable smart width functions only if a control code is found
@@ -1451,6 +1564,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)
@@ -1687,7 +1802,7 @@ def _main():
     -f FMT, --format FMT      set output table format; supported formats:
                               plain, simple, grid, fancy_grid, pipe, orgtbl,
                               rst, mediawiki, html, latex, latex_raw,
-                              latex_booktabs, tsv
+                              latex_booktabs, latex_longtable, tsv
                               (default: simple)
     """
     import getopt
diff --git a/tox.ini b/tox.ini
index c9f4e98..97d6633 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
@@ -19,10 +19,10 @@ passenv =
     REQUESTS_CA_BUNDLE
     SSL_CERT_FILE
 
-[testenv:lint]
-commands = python -m pre_commit run -a
-deps =
-    pre-commit
+#[testenv:lint]
+#commands = python -m pre_commit run -a
+#deps =
+#    pre-commit
 
 [testenv:py27-extra]
 basepython = python2.7
@@ -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_api.py b/test/test_api.py
index 9b5d74f..bf56e82 100644
--- a/test/test_api.py
+++ b/test/test_api.py
@@ -46,8 +46,8 @@ def test_tabulate_signature():
         ("headers", ()),
         ("tablefmt", "simple"),
         ("floatfmt", "g"),
-        ("numalign", "decimal"),
-        ("stralign", "left"),
+        ("numalign", "default"),
+        ("stralign", "default"),
         ("missingval", ""),
     ]
     _check_signature(tabulate, expected_sig)
diff --git a/test/test_input.py b/test/test_input.py
index ee00a7b..17f5b08 100644
--- a/test/test_input.py
+++ b/test/test_input.py
@@ -6,6 +6,11 @@ from __future__ import print_function
 from __future__ import unicode_literals
 from tabulate import tabulate
 from common import assert_equal, assert_in, raises, skip
+try:
+    from collections import UserDict
+except ImportError:
+    # Python2
+    from UserDict import UserDict
 
 
 def test_iterable_of_iterables():
@@ -377,6 +382,13 @@ def test_list_of_dicts():
     result = tabulate(lod)
     assert_in(result, [expected1, expected2])
 
+def test_list_of_userdicts():
+    "Input: a list of UserDicts."
+    lod = [UserDict(foo=1, bar=2), UserDict(foo=3, bar=4)]
+    expected1 = "\n".join(["-  -", "1  2", "3  4", "-  -"])
+    expected2 = "\n".join(["-  -", "2  1", "4  3", "-  -"])
+    result = tabulate(lod)
+    assert_in(result, [expected1, expected2])
 
 def test_list_of_dicts_keys():
     "Input: a list of dictionaries, with keys as headers."
@@ -390,6 +402,17 @@ def test_list_of_dicts_keys():
     result = tabulate(lod, headers="keys")
     assert_in(result, [expected1, expected2])
 
+def test_list_of_userdicts_keys():
+    "Input: a list of UserDicts."
+    lod = [UserDict(foo=1, bar=2), UserDict(foo=3, bar=4)]
+    expected1 = "\n".join(
+        ["  foo    bar", "-----  -----", "    1      2", "    3      4"]
+    )
+    expected2 = "\n".join(
+        ["  bar    foo", "-----  -----", "    2      1", "    4      3"]
+    )
+    result = tabulate(lod, headers="keys")
+    assert_in(result, [expected1, expected2])
 
 def test_list_of_dicts_with_missing_keys():
     "Input: a list of dictionaries, with missing keys."
diff --git a/test/test_output.py b/test/test_output.py
index 3951354..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 = [
@@ -1004,6 +1075,11 @@ def test_moinmoin_headerless():
 
 _test_table_html_headers = ["<strings>", "<&numbers&>"]
 _test_table_html = [["spam >", 41.9999], ["eggs &", 451.0]]
+_test_table_unsafehtml_headers = ["strings", "numbers"]
+_test_table_unsafehtml = [
+    ["spam", '<font color="red">41.9999</font>'],
+    ["eggs", '<font color="red">451.0</font>'],
+]
 
 
 def test_html():
@@ -1027,6 +1103,29 @@ def test_html():
     assert result._repr_html_() == result.str
 
 
+def test_unsafehtml():
+    "Output: unsafe html with headers"
+    expected = "\n".join(
+        [
+            "<table>",
+            "<thead>",
+            "<tr><th>strings  </th><th>numbers                         </th></tr>",  # noqa
+            "</thead>",
+            "<tbody>",
+            '<tr><td>spam     </td><td><font color="red">41.9999</font></td></tr>',
+            '<tr><td>eggs     </td><td><font color="red">451.0</font>  </td></tr>',
+            "</tbody>",
+            "</table>",
+        ]
+    )
+    result = tabulate(
+        _test_table_unsafehtml, _test_table_unsafehtml_headers, tablefmt="unsafehtml"
+    )
+    assert_equal(expected, result)
+    assert hasattr(result, "_repr_html_")
+    assert result._repr_html_() == result.str
+
+
 def test_html_headerless():
     "Output: html without headers"
     expected = "\n".join(
@@ -1045,6 +1144,24 @@ def test_html_headerless():
     assert result._repr_html_() == result.str
 
 
+def test_unsafehtml_headerless():
+    "Output: unsafe html without headers"
+    expected = "\n".join(
+        [
+            "<table>",
+            "<tbody>",
+            '<tr><td>spam</td><td><font color="red">41.9999</font></td></tr>',
+            '<tr><td>eggs</td><td><font color="red">451.0</font>  </td></tr>',
+            "</tbody>",
+            "</table>",
+        ]
+    )
+    result = tabulate(_test_table_unsafehtml, tablefmt="unsafehtml")
+    assert_equal(expected, result)
+    assert hasattr(result, "_repr_html_")
+    assert result._repr_html_() == result.str
+
+
 def test_latex():
     "Output: latex with headers and replaced characters"
     raw_test_table_headers = list(_test_table_headers)
@@ -1317,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..ae9d69b 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)."
 
@@ -226,6 +272,18 @@ def test_alignment_of_decimal_numbers_with_ansi_color():
     assert_equal(result, expected)
 
 
+def test_alignment_of_decimal_numbers_with_commas():
+    "Regression: alignment for decimal numbers with comma separators"
+    table = [["c1r1", "14502.05"], ["c1r2", 105]]
+    result = tabulate(table, tablefmt="grid", floatfmt=',.2f')
+    expected = "\n".join(
+        ['+------+-----------+', '| c1r1 | 14,502.05 |',
+        '+------+-----------+', '| c1r2 |    105.00 |',
+        '+------+-----------+']
+    )
+    assert_equal(result, expected)
+
+
 def test_long_integers():
     "Regression: long integers should be printed as integers (issue #48)"
     table = [[18446744073709551614]]
@@ -276,6 +334,26 @@ def test_mix_normal_and_wide_characters():
         skip("test_mix_normal_and_wide_characters is skipped (requires wcwidth lib)")
 
 
+def test_multiline_with_wide_characters():
+    "Regression: multiline tables with varying number of wide characters (github issue #28)"
+    try:
+        import wcwidth  # noqa
+
+        table = [["가나\n가ab", "가나", "가나"]]
+        result = tabulate(table, tablefmt="fancy_grid")
+        expected = "\n".join(
+            [
+                "╒══════╤══════╤══════╕",
+                "│ 가나 │ 가나 │ 가나 │",
+                "│ 가ab │      │      │",
+                "╘══════╧══════╧══════╛",
+            ]
+        )
+        assert_equal(result, expected)
+    except ImportError:
+        skip("test_multiline_with_wide_characters is skipped (requires wcwidth lib)")
+
+
 def test_align_long_integers():
     "Regression: long integers should be aligned as integers (issue #61)"
     table = [[_long_type(1)], [_long_type(234)]]