Repository: astanin/python-tabulate

Issue:
PRESERVE_WHITESPACE should be made an argument
It's currently a module-level global variable. This is assuming that all users of tabulate uses the same PRESERVE_WHITESPACE setting. This will not work well if different use of tabulate needs different settings.

Having an argument for this setting would be more general.

Discussion:

Solution patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index bc19353..0000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,56 +0,0 @@
-# Python CircleCI 2.0 configuration file
-#
-# Check https://circleci.com/docs/2.0/language-python/ for more details
-#
-version: 2
-jobs:
-  build:
-    docker:
-      # specify the version you desire here
-      # use `-browsers` prefix for selenium tests, e.g. `3.6.1-browsers`
-      - image: circleci/python:3.8
-
-      # Specify service dependencies here if necessary
-      # CircleCI maintains a library of pre-built images
-      # documented at https://circleci.com/docs/2.0/circleci-images/
-      # - image: circleci/postgres:9.4
-
-    working_directory: ~/repo
-
-    steps:
-      - checkout
-
-      # Download and cache dependencies
-      - restore_cache:
-          keys:
-          - v1-dependencies-{{ checksum ".circleci/requirements.txt" }}
-          # fallback to using the latest cache if no exact match is found
-          - v1-dependencies-
-
-      - run:
-          name: install dependencies
-          command: |
-            python3 -m venv venv
-            . venv/bin/activate
-            pip install -r .circleci/requirements.txt
-
-      - save_cache:
-          paths:
-            - ./venv
-          key: v1-dependencies-{{ checksum ".circleci/requirements.txt" }}
-
-      - run:
-          name: run tests
-          command: |
-            . venv/bin/activate
-            tox -e py38-extra
-
-      - run:
-          name: run linting
-          command: |
-            . venv/bin/activate
-            tox -e lint
-
-      - store_artifacts:
-          path: test-reports
-          destination: test-reports
diff --git a/.circleci/requirements.txt b/.circleci/requirements.txt
deleted file mode 100644
index 650d583..0000000
--- a/.circleci/requirements.txt
+++ /dev/null
@@ -1,5 +0,0 @@
-pytest
-tox
-numpy
-pandas
-wcwidth
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
new file mode 100644
index 0000000..7e7c906
--- /dev/null
+++ b/.github/workflows/lint.yml
@@ -0,0 +1,26 @@
+name: lint
+
+on:
+  - push
+  - pull_request
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: ['3.12']
+
+    steps:
+    - uses: actions/checkout@v4
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v4
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        python -m pip install tox tox-gh
+    - name: Run linters
+      run: |
+        tox -e lint
diff --git a/.github/workflows/tabulate.yml b/.github/workflows/tabulate.yml
new file mode 100644
index 0000000..9adabb7
--- /dev/null
+++ b/.github/workflows/tabulate.yml
@@ -0,0 +1,26 @@
+name: python-tabulate
+
+on:
+  - push
+  - pull_request
+
+jobs:
+  build:
+    runs-on: ubuntu-latest
+    strategy:
+      matrix:
+        python-version: ['3.9', '3.10', '3.11', '3.12']
+
+    steps:
+    - uses: actions/checkout@v4
+    - name: Set up Python ${{ matrix.python-version }}
+      uses: actions/setup-python@v4
+      with:
+        python-version: ${{ matrix.python-version }}
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        python -m pip install pytest numpy pandas
+    - name: Run tests
+      run: |
+        pytest -v --doctest-modules --ignore benchmark.py
diff --git a/.gitignore b/.gitignore
index 460933e..0495ac7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
+/tabulate/version.py
+
 build
 dist
 .tox
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index e6f9951..7349858 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
 repos:
 -   repo: https://github.com/python/black
-    rev: 19.3b0
+    rev: 22.3.0
     hooks:
     -   id: black
         args: [--safe]
diff --git a/CHANGELOG b/CHANGELOG
index 8f7e97c..a18a5a1 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -1,4 +1,14 @@
-- 0.8.10: Future version
+- 0.9.1: Future version.
+- 0.9.0: Drop support for Python 2.7, 3.5, 3.6.
+  Migrate to pyproject.toml project layout (PEP 621).
+  New output formats: `asciidoc`, various `*grid` and `*outline` formats.
+  New output features: vertical row alignment, separating lines.
+  New input format: list of dataclasses (Python 3.7 or later).
+  Support infinite iterables as row indices.
+  Improve column width options.
+  Improve support for ANSI escape sequences and document the behavior.
+  Various bug fixes.
+- 0.8.10: Python 3.10 support. Bug fixes. Column width parameter.
 - 0.8.9: Bug fix. Revert support of decimal separators.
 - 0.8.8: Python 3.9 support, 3.10 ready.
   New formats: ``unsafehtml``, ``latex_longtable``, ``fancy_outline``.
@@ -11,7 +21,7 @@
 - 0.8.6: Bug fixes. Stop supporting Python 3.3, 3.4.
 - 0.8.5: Fix broken Windows package. Minor documentation updates.
 - 0.8.4: Bug fixes.
-- 0.8.3: New formats: `github`. Custom colum alignment. Bug fixes.
+- 0.8.3: New formats: `github`. Custom column alignment. Bug fixes.
 - 0.8.2: Bug fixes.
 - 0.8.1: Multiline data in several output formats.
   New ``latex_raw`` format.
diff --git a/HOWTOPUBLISH b/HOWTOPUBLISH
index 6730e8a..795fc73 100644
--- a/HOWTOPUBLISH
+++ b/HOWTOPUBLISH
@@ -1,8 +1,7 @@
 # update contributors and CHANGELOG in README
+# tag version release
 python3 benchmark.py  # then update README
-tox -e py33,py34,py36-extra
-python3 setup.py sdist bdist_wheel
+tox -e py38-extra,py39-extra,py310-extra,py311-extra,py312-extra
+python3 -m build -nswx .
 twine upload --repository-url https://test.pypi.org/legacy/ dist/*
 twine upload dist/*
-# tag version release
-# bump version number in setup.py in tabulate.py
diff --git a/README.md b/README.md
index 9ccb100..b596cf5 100644
--- a/README.md
+++ b/README.md
@@ -19,16 +19,19 @@ Installation
 
 To install the Python library and the command line utility, run:
 
-    pip install tabulate
+```shell
+pip install tabulate
+```
 
 The command line utility will be installed as `tabulate` to `bin` on
 Linux (e.g. `/usr/bin`); or as `tabulate.exe` to `Scripts` in your
-Python installation on Windows (e.g.
-`C:\Python27\Scripts\tabulate.exe`).
+Python installation on Windows (e.g. `C:\Python39\Scripts\tabulate.exe`).
 
 You may consider installing the library only for the current user:
 
-    pip install tabulate --user
+```shell
+pip install tabulate --user
+```
 
 In this case the command line utility will be installed to
 `~/.local/bin/tabulate` on Linux and to
@@ -36,17 +39,21 @@ In this case the command line utility will be installed to
 
 To install just the library on Unix-like operating systems:
 
-    TABULATE_INSTALL=lib-only pip install tabulate
+```shell
+TABULATE_INSTALL=lib-only pip install tabulate
+```
 
 On Windows:
 
-    set TABULATE_INSTALL=lib-only
-    pip install tabulate
+```shell
+set TABULATE_INSTALL=lib-only
+pip install tabulate
+```
 
 Build status
 ------------
 
-[![Build status](https://circleci.com/gh/astanin/python-tabulate.svg?style=svg)](https://circleci.com/gh/astanin/python-tabulate/tree/master) [![Build status](https://ci.appveyor.com/api/projects/status/8745yksvvol7h3d7/branch/master?svg=true)](https://ci.appveyor.com/project/astanin/python-tabulate/branch/master)
+[![python-tabulate](https://github.com/astanin/python-tabulate/actions/workflows/tabulate.yml/badge.svg)](https://github.com/astanin/python-tabulate/actions/workflows/tabulate.yml) [![Build status](https://ci.appveyor.com/api/projects/status/8745yksvvol7h3d7/branch/master?svg=true)](https://ci.appveyor.com/project/astanin/python-tabulate/branch/master)
 
 Library usage
 -------------
@@ -55,61 +62,82 @@ The module provides just one function, `tabulate`, which takes a list of
 lists or another tabular data type as the first argument, and outputs a
 nicely formatted plain-text table:
 
-    >>> from tabulate import tabulate
+```pycon
+>>> from tabulate import tabulate
 
-    >>> table = [["Sun",696000,1989100000],["Earth",6371,5973.6],
-    ...          ["Moon",1737,73.5],["Mars",3390,641.85]]
-    >>> print(tabulate(table))
-    -----  ------  -------------
-    Sun    696000     1.9891e+09
-    Earth    6371  5973.6
-    Moon     1737    73.5
-    Mars     3390   641.85
-    -----  ------  -------------
+>>> table = [["Sun",696000,1989100000],["Earth",6371,5973.6],
+...          ["Moon",1737,73.5],["Mars",3390,641.85]]
+>>> print(tabulate(table))
+-----  ------  -------------
+Sun    696000     1.9891e+09
+Earth    6371  5973.6
+Moon     1737    73.5
+Mars     3390   641.85
+-----  ------  -------------
+```
 
 The following tabular data types are supported:
 
 -   list of lists or another iterable of iterables
 -   list or another iterable of dicts (keys as columns)
 -   dict of iterables (keys as columns)
+-   list of dataclasses (field names as columns)
 -   two-dimensional NumPy array
 -   NumPy record arrays (names as columns)
 -   pandas.DataFrame
 
-Examples in this file use Python2. Tabulate supports Python3 too.
+Tabulate is a Python3 library.
 
 ### Headers
 
 The second optional argument named `headers` defines a list of column
 headers to be used:
 
-    >>> print(tabulate(table, headers=["Planet","R (km)", "mass (x 10^29 kg)"]))
-    Planet      R (km)    mass (x 10^29 kg)
-    --------  --------  -------------------
-    Sun         696000           1.9891e+09
-    Earth         6371        5973.6
-    Moon          1737          73.5
-    Mars          3390         641.85
+```pycon
+>>> print(tabulate(table, headers=["Planet","R (km)", "mass (x 10^29 kg)"]))
+Planet      R (km)    mass (x 10^29 kg)
+--------  --------  -------------------
+Sun         696000           1.9891e+09
+Earth         6371        5973.6
+Moon          1737          73.5
+Mars          3390         641.85
+```
 
 If `headers="firstrow"`, then the first row of data is used:
 
-    >>> print(tabulate([["Name","Age"],["Alice",24],["Bob",19]],
-    ...                headers="firstrow"))
-    Name      Age
-    ------  -----
-    Alice      24
-    Bob        19
+```pycon
+>>> print(tabulate([["Name","Age"],["Alice",24],["Bob",19]],
+...                headers="firstrow"))
+Name      Age
+------  -----
+Alice      24
+Bob        19
+```
 
 If `headers="keys"`, then the keys of a dictionary/dataframe, or column
 indices are used. It also works for NumPy record arrays and lists of
 dictionaries or named tuples:
 
-    >>> print(tabulate({"Name": ["Alice", "Bob"],
-    ...                 "Age": [24, 19]}, headers="keys"))
-      Age  Name
-    -----  ------
-       24  Alice
-       19  Bob
+```pycon
+>>> print(tabulate({"Name": ["Alice", "Bob"],
+...                 "Age": [24, 19]}, headers="keys"))
+Name      Age
+------  -----
+Alice      24
+Bob        19
+```
+
+When data is a list of dictionaries, a dictionary can be passed as `headers`
+to replace the keys with other column labels:
+
+```pycon
+>>> print(tabulate([{1: "Alice", 2: 24}, {1: "Bob", 2: 19}], 
+...                headers={1: "Name", 2: "Age"}))
+Name      Age
+------  -----
+Alice      24
+Bob        19
+```
 
 ### Row Indices
 
@@ -120,11 +148,13 @@ To suppress row indices for all types of data, pass `showindex="never"`
 or `showindex=False`. To add a custom row index column, pass
 `showindex=rowIDs`, where `rowIDs` is some iterable:
 
-    >>> print(tabulate([["F",24],["M",19]], showindex="always"))
-    -  -  --
-    0  F  24
-    1  M  19
-    -  -  --
+```pycon
+>>> print(tabulate([["F",24],["M",19]], showindex="always"))
+-  -  --
+0  F  24
+1  M  19
+-  -  --
+```
 
 ### Table format
 
@@ -137,9 +167,22 @@ Supported table formats are:
 -   "simple"
 -   "github"
 -   "grid"
+-   "simple\_grid"
+-   "rounded\_grid"
+-   "heavy\_grid"
+-   "mixed\_grid"
+-   "double\_grid"
 -   "fancy\_grid"
+-   "outline"
+-   "simple\_outline"
+-   "rounded\_outline"
+-   "heavy\_outline"
+-   "mixed\_outline"
+-   "double\_outline"
+-   "fancy\_outline"
 -   "pipe"
 -   "orgtbl"
+-   "asciidoc"
 -   "jira"
 -   "presto"
 -   "pretty"
@@ -159,211 +202,405 @@ Supported table formats are:
 
 `plain` tables do not use any pseudo-graphics to draw lines:
 
-    >>> table = [["spam",42],["eggs",451],["bacon",0]]
-    >>> headers = ["item", "qty"]
-    >>> print(tabulate(table, headers, tablefmt="plain"))
-    item      qty
-    spam       42
-    eggs      451
-    bacon       0
+```pycon
+>>> table = [["spam",42],["eggs",451],["bacon",0]]
+>>> headers = ["item", "qty"]
+>>> print(tabulate(table, headers, tablefmt="plain"))
+item      qty
+spam       42
+eggs      451
+bacon       0
+```
 
 `simple` is the default format (the default may change in future
 versions). It corresponds to `simple_tables` in [Pandoc Markdown
 extensions](http://johnmacfarlane.net/pandoc/README.html#tables):
 
-    >>> print(tabulate(table, headers, tablefmt="simple"))
-    item      qty
-    ------  -----
-    spam       42
-    eggs      451
-    bacon       0
+```pycon
+>>> print(tabulate(table, headers, tablefmt="simple"))
+item      qty
+------  -----
+spam       42
+eggs      451
+bacon       0
+```
 
 `github` follows the conventions of GitHub flavored Markdown. It
 corresponds to the `pipe` format without alignment colons:
 
-    >>> print(tabulate(table, headers, tablefmt="github"))
-    | item   | qty   |
-    |--------|-------|
-    | spam   | 42    |
-    | eggs   | 451   |
-    | bacon  | 0     |
+```pycon
+>>> print(tabulate(table, headers, tablefmt="github"))
+| item   | qty   |
+|--------|-------|
+| spam   | 42    |
+| eggs   | 451   |
+| bacon  | 0     |
+```
 
 `grid` is like tables formatted by Emacs'
 [table.el](http://table.sourceforge.net/) package. It corresponds to
 `grid_tables` in Pandoc Markdown extensions:
 
-    >>> print(tabulate(table, headers, tablefmt="grid"))
+```pycon
+>>> print(tabulate(table, headers, tablefmt="grid"))
++--------+-------+
+| item   |   qty |
++========+=======+
+| spam   |    42 |
++--------+-------+
+| eggs   |   451 |
++--------+-------+
+| bacon  |     0 |
++--------+-------+
+```
+
+`simple_grid` draws a grid using single-line box-drawing characters:
+
+    >>> print(tabulate(table, headers, tablefmt="simple_grid"))
+    ┌────────┬───────┐
+    │ item   │   qty │
+    ├────────┼───────┤
+    │ spam   │    42 │
+    ├────────┼───────┤
+    │ eggs   │   451 │
+    ├────────┼───────┤
+    │ bacon  │     0 │
+    └────────┴───────┘
+
+`rounded_grid` draws a grid using single-line box-drawing characters with rounded corners:
+
+    >>> print(tabulate(table, headers, tablefmt="rounded_grid"))
+    ╭────────┬───────╮
+    │ item   │   qty │
+    ├────────┼───────┤
+    │ spam   │    42 │
+    ├────────┼───────┤
+    │ eggs   │   451 │
+    ├────────┼───────┤
+    │ bacon  │     0 │
+    ╰────────┴───────╯
+
+`heavy_grid` draws a grid using bold (thick) single-line box-drawing characters:
+
+    >>> print(tabulate(table, headers, tablefmt="heavy_grid"))
+    ┏━━━━━━━━┳━━━━━━━┓
+    ┃ item   ┃   qty ┃
+    ┣━━━━━━━━╋━━━━━━━┫
+    ┃ spam   ┃    42 ┃
+    ┣━━━━━━━━╋━━━━━━━┫
+    ┃ eggs   ┃   451 ┃
+    ┣━━━━━━━━╋━━━━━━━┫
+    ┃ bacon  ┃     0 ┃
+    ┗━━━━━━━━┻━━━━━━━┛
+
+`mixed_grid` draws a grid using a mix of light (thin) and heavy (thick) lines box-drawing characters:
+
+    >>> print(tabulate(table, headers, tablefmt="mixed_grid"))
+    ┍━━━━━━━━┯━━━━━━━┑
+    │ item   │   qty │
+    ┝━━━━━━━━┿━━━━━━━┥
+    │ spam   │    42 │
+    ├────────┼───────┤
+    │ eggs   │   451 │
+    ├────────┼───────┤
+    │ bacon  │     0 │
+    ┕━━━━━━━━┷━━━━━━━┙
+
+`double_grid` draws a grid using double-line box-drawing characters:
+
+    >>> print(tabulate(table, headers, tablefmt="double_grid"))
+    ╔════════╦═══════╗
+    ║ item   ║   qty ║
+    ╠════════╬═══════╣
+    ║ spam   ║    42 ║
+    ╠════════╬═══════╣
+    ║ eggs   ║   451 ║
+    ╠════════╬═══════╣
+    ║ bacon  ║     0 ║
+    ╚════════╩═══════╝
+
+`fancy_grid` draws a grid using a mix of single and
+    double-line box-drawing characters:
+
+```pycon
+>>> print(tabulate(table, headers, tablefmt="fancy_grid"))
+╒════════╤═══════╕
+│ item   │   qty │
+╞════════╪═══════╡
+│ spam   │    42 │
+├────────┼───────┤
+│ eggs   │   451 │
+├────────┼───────┤
+│ bacon  │     0 │
+╘════════╧═══════╛
+```
+
+`outline` is the same as the `grid` format but doesn't draw lines between rows:
+
+    >>> print(tabulate(table, headers, tablefmt="outline"))
     +--------+-------+
     | item   |   qty |
     +========+=======+
     | spam   |    42 |
-    +--------+-------+
     | eggs   |   451 |
-    +--------+-------+
     | bacon  |     0 |
     +--------+-------+
 
-`fancy_grid` draws a grid using box-drawing characters:
+`simple_outline` is the same as the `simple_grid` format but doesn't draw lines between rows:
+
+    >>> print(tabulate(table, headers, tablefmt="simple_outline"))
+    ┌────────┬───────┐
+    │ item   │   qty │
+    ├────────┼───────┤
+    │ spam   │    42 │
+    │ eggs   │   451 │
+    │ bacon  │     0 │
+    └────────┴───────┘
+
+`rounded_outline` is the same as the `rounded_grid` format but doesn't draw lines between rows:
+
+    >>> print(tabulate(table, headers, tablefmt="rounded_outline"))
+    ╭────────┬───────╮
+    │ item   │   qty │
+    ├────────┼───────┤
+    │ spam   │    42 │
+    │ eggs   │   451 │
+    │ bacon  │     0 │
+    ╰────────┴───────╯
+
+`heavy_outline` is the same as the `heavy_grid` format but doesn't draw lines between rows:
+
+    >>> print(tabulate(table, headers, tablefmt="heavy_outline"))
+    ┏━━━━━━━━┳━━━━━━━┓
+    ┃ item   ┃   qty ┃
+    ┣━━━━━━━━╋━━━━━━━┫
+    ┃ spam   ┃    42 ┃
+    ┃ eggs   ┃   451 ┃
+    ┃ bacon  ┃     0 ┃
+    ┗━━━━━━━━┻━━━━━━━┛
+
+`mixed_outline` is the same as the `mixed_grid` format but doesn't draw lines between rows:
+
+    >>> print(tabulate(table, headers, tablefmt="mixed_outline"))
+    ┍━━━━━━━━┯━━━━━━━┑
+    │ item   │   qty │
+    ┝━━━━━━━━┿━━━━━━━┥
+    │ spam   │    42 │
+    │ eggs   │   451 │
+    │ bacon  │     0 │
+    ┕━━━━━━━━┷━━━━━━━┙
 
-    >>> print(tabulate(table, headers, tablefmt="fancy_grid"))
+`double_outline` is the same as the `double_grid` format but doesn't draw lines between rows:
+
+    >>> print(tabulate(table, headers, tablefmt="double_outline"))
+    ╔════════╦═══════╗
+    ║ item   ║   qty ║
+    ╠════════╬═══════╣
+    ║ spam   ║    42 ║
+    ║ eggs   ║   451 ║
+    ║ bacon  ║     0 ║
+    ╚════════╩═══════╝
+
+`fancy_outline` is the same as the `fancy_grid` format but doesn't draw lines between rows:
+
+    >>> print(tabulate(table, headers, tablefmt="fancy_outline"))
     ╒════════╤═══════╕
     │ item   │   qty │
     ╞════════╪═══════╡
     │ spam   │    42 │
-    ├────────┼───────┤
     │ eggs   │   451 │
-    ├────────┼───────┤
     │ bacon  │     0 │
     ╘════════╧═══════╛
 
 `presto` is like tables formatted by Presto cli:
 
-    >>> print(tabulate(table, headers, tablefmt="presto"))
-     item   |   qty
-    --------+-------
-     spam   |    42
-     eggs   |   451
-     bacon  |     0
+```pycon
+>>> print(tabulate(table, headers, tablefmt="presto"))
+ item   |   qty
+--------+-------
+ spam   |    42
+ eggs   |   451
+ bacon  |     0
+```
 
 `pretty` attempts to be close to the format emitted by the PrettyTables
 library:
 
-    >>> print(tabulate(table, headers, tablefmt="pretty"))
-    +-------+-----+
-    | item  | qty |
-    +-------+-----+
-    | spam  | 42  |
-    | eggs  | 451 |
-    | bacon |  0  |
-    +-------+-----+
+```pycon
+>>> print(tabulate(table, headers, tablefmt="pretty"))
++-------+-----+
+| item  | qty |
++-------+-----+
+| spam  | 42  |
+| eggs  | 451 |
+| bacon |  0  |
++-------+-----+
+```
 
 `psql` is like tables formatted by Postgres' psql cli:
 
-    >>> print(tabulate(table, headers, tablefmt="psql"))
-    +--------+-------+
-    | item   |   qty |
-    |--------+-------|
-    | spam   |    42 |
-    | eggs   |   451 |
-    | bacon  |     0 |
-    +--------+-------+
+```pycon
+>>> print(tabulate(table, headers, tablefmt="psql"))
++--------+-------+
+| item   |   qty |
+|--------+-------|
+| spam   |    42 |
+| eggs   |   451 |
+| bacon  |     0 |
++--------+-------+
+```
 
 `pipe` follows the conventions of [PHP Markdown
 Extra](http://michelf.ca/projects/php-markdown/extra/#table) extension.
 It corresponds to `pipe_tables` in Pandoc. This format uses colons to
 indicate column alignment:
 
-    >>> print(tabulate(table, headers, tablefmt="pipe"))
-    | item   |   qty |
-    |:-------|------:|
-    | spam   |    42 |
-    | eggs   |   451 |
-    | bacon  |     0 |
+```pycon
+>>> print(tabulate(table, headers, tablefmt="pipe"))
+| item   |   qty |
+|:-------|------:|
+| spam   |    42 |
+| eggs   |   451 |
+| bacon  |     0 |
+```
+
+`asciidoc` formats data like a simple table of the
+[AsciiDoctor](https://docs.asciidoctor.org/asciidoc/latest/syntax-quick-reference/#tables)
+format:
+
+```pycon
+>>> print(tabulate(table, headers, tablefmt="asciidoc"))
+[cols="8<,7>",options="header"]
+|====
+| item   |   qty
+| spam   |    42
+| eggs   |   451
+| bacon  |     0
+|====
+```
 
 `orgtbl` follows the conventions of Emacs
 [org-mode](http://orgmode.org/manual/Tables.html), and is editable also
 in the minor orgtbl-mode. Hence its name:
 
-    >>> print(tabulate(table, headers, tablefmt="orgtbl"))
-    | item   |   qty |
-    |--------+-------|
-    | spam   |    42 |
-    | eggs   |   451 |
-    | bacon  |     0 |
+```pycon
+>>> print(tabulate(table, headers, tablefmt="orgtbl"))
+| item   |   qty |
+|--------+-------|
+| spam   |    42 |
+| eggs   |   451 |
+| bacon  |     0 |
+```
 
 `jira` follows the conventions of Atlassian Jira markup language:
 
-    >>> print(tabulate(table, headers, tablefmt="jira"))
-    || item   ||   qty ||
-    | spam   |    42 |
-    | eggs   |   451 |
-    | bacon  |     0 |
+```pycon
+>>> print(tabulate(table, headers, tablefmt="jira"))
+|| item   ||   qty ||
+| spam   |    42 |
+| eggs   |   451 |
+| bacon  |     0 |
+```
 
 `rst` formats data like a simple table of the
 [reStructuredText](http://docutils.sourceforge.net/docs/user/rst/quickref.html#tables)
 format:
 
-    >>> print(tabulate(table, headers, tablefmt="rst"))
-    ======  =====
-    item      qty
-    ======  =====
-    spam       42
-    eggs      451
-    bacon       0
-    ======  =====
+```pycon
+>>> print(tabulate(table, headers, tablefmt="rst"))
+======  =====
+item      qty
+======  =====
+spam       42
+eggs      451
+bacon       0
+======  =====
+```
 
 `mediawiki` format produces a table markup used in
 [Wikipedia](http://www.mediawiki.org/wiki/Help:Tables) and on other
 MediaWiki-based sites:
 
-    >>> print(tabulate(table, headers, tablefmt="mediawiki"))
-    {| class="wikitable" style="text-align: left;"
-    |+ <!-- caption -->
-    |-
-    ! item   !! align="right"|   qty
-    |-
-    | spam   || align="right"|    42
-    |-
-    | eggs   || align="right"|   451
-    |-
-    | bacon  || align="right"|     0
-    |}
+ ```pycon
+>>> print(tabulate(table, headers, tablefmt="mediawiki"))
+{| class="wikitable" style="text-align: left;"
+|+ <!-- caption -->
+|-
+! item   !! style="text-align: right;"|   qty
+|-
+| spam   || style="text-align: right;"|    42
+|-
+| eggs   || style="text-align: right;"|   451
+|-
+| bacon  || style="text-align: right;"|     0
+|}
+```
 
 `moinmoin` format produces a table markup used in
 [MoinMoin](https://moinmo.in/) wikis:
 
-    >>> print(tabulate(table, headers, tablefmt="moinmoin"))
-    || ''' item   ''' || ''' quantity   ''' ||
-    ||  spam    ||  41.999      ||
-    ||  eggs    ||  451         ||
-    ||  bacon   ||              ||
+```pycon
+>>> print(tabulate(table, headers, tablefmt="moinmoin"))
+|| ''' item   ''' || ''' quantity   ''' ||
+||  spam    ||  41.999      ||
+||  eggs    ||  451         ||
+||  bacon   ||              ||
+```
 
 `youtrack` format produces a table markup used in Youtrack tickets:
 
-    >>> print(tabulate(table, headers, tablefmt="youtrack"))
-    ||  item    ||  quantity   ||
-    |   spam    |  41.999      |
-    |   eggs    |  451         |
-    |   bacon   |              |
+```pycon
+>>> print(tabulate(table, headers, tablefmt="youtrack"))
+||  item    ||  quantity   ||
+|   spam    |  41.999      |
+|   eggs    |  451         |
+|   bacon   |              |
+```
 
 `textile` format produces a table markup used in
 [Textile](http://redcloth.org/hobix.com/textile/) format:
 
-    >>> print(tabulate(table, headers, tablefmt="textile"))
-    |_.  item   |_.   qty |
-    |<. spam    |>.    42 |
-    |<. eggs    |>.   451 |
-    |<. bacon   |>.     0 |
+```pycon
+>>> print(tabulate(table, headers, tablefmt="textile"))
+|_.  item   |_.   qty |
+|<. spam    |>.    42 |
+|<. eggs    |>.   451 |
+|<. bacon   |>.     0 |
+```
 
 `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.
 `unsafehtml` table format can be used if an unescaped HTML is required:
 
-    >>> print(tabulate(table, headers, tablefmt="html"))
-    <table>
-    <tbody>
-    <tr><th>item  </th><th style="text-align: right;">  qty</th></tr>
-    <tr><td>spam  </td><td style="text-align: right;">   42</td></tr>
-    <tr><td>eggs  </td><td style="text-align: right;">  451</td></tr>
-    <tr><td>bacon </td><td style="text-align: right;">    0</td></tr>
-    </tbody>
-    </table>
+```pycon
+>>> print(tabulate(table, headers, tablefmt="html"))
+<table>
+<tbody>
+<tr><th>item  </th><th style="text-align: right;">  qty</th></tr>
+<tr><td>spam  </td><td style="text-align: right;">   42</td></tr>
+<tr><td>eggs  </td><td style="text-align: right;">  451</td></tr>
+<tr><td>bacon </td><td style="text-align: right;">    0</td></tr>
+</tbody>
+</table>
+```
 
 `latex` format creates a `tabular` environment for LaTeX markup,
 replacing special characters like `_` or `\` to their LaTeX
 correspondents:
 
-    >>> print(tabulate(table, headers, tablefmt="latex"))
-    \begin{tabular}{lr}
-    \hline
-     item   &   qty \\
-    \hline
-     spam   &    42 \\
-     eggs   &   451 \\
-     bacon  &     0 \\
-    \hline
-    \end{tabular}
+```pycon
+>>> print(tabulate(table, headers, tablefmt="latex"))
+\begin{tabular}{lr}
+\hline
+ item   &   qty \\
+\hline
+ spam   &    42 \\
+ eggs   &   451 \\
+ bacon  &     0 \\
+\hline
+\end{tabular}
+```
 
 `latex_raw` behaves like `latex` but does not escape LaTeX commands and
 special characters.
@@ -388,83 +625,116 @@ named arguments. Possible column alignments are: `right`, `center`,
 Aligning by a decimal point works best when you need to compare numbers
 at a glance:
 
-    >>> print(tabulate([[1.2345],[123.45],[12.345],[12345],[1234.5]]))
-    ----------
-        1.2345
-      123.45
-       12.345
-    12345
-     1234.5
-    ----------
+```pycon
+>>> print(tabulate([[1.2345],[123.45],[12.345],[12345],[1234.5]]))
+----------
+    1.2345
+  123.45
+   12.345
+12345
+ 1234.5
+----------
+```
 
 Compare this with a more common right alignment:
 
-    >>> print(tabulate([[1.2345],[123.45],[12.345],[12345],[1234.5]], numalign="right"))
-    ------
-    1.2345
-    123.45
-    12.345
-     12345
-    1234.5
-    ------
+```pycon
+>>> print(tabulate([[1.2345],[123.45],[12.345],[12345],[1234.5]], numalign="right"))
+------
+1.2345
+123.45
+12.345
+ 12345
+1234.5
+------
+```
 
 For `tabulate`, anything which can be parsed as a number is a number.
 Even numbers represented as strings are aligned properly. This feature
 comes in handy when reading a mixed table of text and numbers from a
 file:
 
-    >>> import csv ; from StringIO import StringIO
-    >>> table = list(csv.reader(StringIO("spam, 42\neggs, 451\n")))
-    >>> table
-    [['spam', ' 42'], ['eggs', ' 451']]
-    >>> print(tabulate(table))
-    ----  ----
-    spam    42
-    eggs   451
-    ----  ----
-
+```pycon
+>>> import csv ; from StringIO import StringIO
+>>> table = list(csv.reader(StringIO("spam, 42\neggs, 451\n")))
+>>> table
+[['spam', ' 42'], ['eggs', ' 451']]
+>>> print(tabulate(table))
+----  ----
+spam    42
+eggs   451
+----  ----
+```
 
 To disable this feature use `disable_numparse=True`.
 
-    >>> print(tabulate.tabulate([["Ver1", "18.0"], ["Ver2","19.2"]], tablefmt="simple", disable_numparse=True))
-    ----  ----
-    Ver1  18.0
-    Ver2  19.2
-    ----  ----
-
+```pycon
+>>> print(tabulate.tabulate([["Ver1", "18.0"], ["Ver2","19.2"]], tablefmt="simple", disable_numparse=True))
+----  ----
+Ver1  18.0
+Ver2  19.2
+----  ----
+```
 
 ### Custom column alignment
 
-`tabulate` allows a custom column alignment to override the above. The
-`colalign` argument can be a list or a tuple of `stralign` named
-arguments. Possible column alignments are: `right`, `center`, `left`,
-`decimal` (only for numbers), and `None` (to disable alignment).
-Omitting an alignment uses the default. For example:
+`tabulate` allows a custom column alignment to override the smart alignment described above.
+Use `colglobalalign` to define a global setting. Possible alignments are: `right`, `center`, `left`, `decimal` (only for numbers).
+Furthermore, you can define `colalign` for column-specific alignment as a list or a tuple. Possible values are `global` (keeps global setting), `right`, `center`, `left`, `decimal` (only for numbers), `None` (to disable alignment). Missing alignments are treated as `global`.
 
-    >>> print(tabulate([["one", "two"], ["three", "four"]], colalign=("right",))
-    -----  ----
-      one  two
-    three  four
-    -----  ----
+```pycon
+>>> print(tabulate([[1,2,3,4],[111,222,333,444]], colglobalalign='center', colalign = ('global','left','right')))
+---  ---  ---  ---
+ 1   2      3   4
+111  222  333  444
+---  ---  ---  ---
+```
+
+### Custom header alignment
+
+Headers' alignment can be defined separately from columns'. Like for columns, you can use:
+- `headersglobalalign` to define a header-specific global alignment setting. Possible values are `right`, `center`, `left`, `None` (to follow column alignment),
+- `headersalign` list or tuple to further specify header-wise alignment. Possible values are `global` (keeps global setting), `same` (follow column alignment), `right`, `center`, `left`, `None` (to disable alignment). Missing alignments are treated as `global`.
+
+```pycon
+>>> print(tabulate([[1,2,3,4,5,6],[111,222,333,444,555,666]], colglobalalign = 'center', colalign = ('left',), headers = ['h','e','a','d','e','r'], headersglobalalign = 'right', headersalign = ('same','same','left','global','center')))
+
+h     e   a      d   e     r
+---  ---  ---  ---  ---  ---
+1     2    3    4    5    6
+111  222  333  444  555  666
+```
 
 ### Number formatting
 
 `tabulate` allows to define custom number formatting applied to all
 columns of decimal numbers. Use `floatfmt` named argument:
 
-    >>> print(tabulate([["pi",3.141593],["e",2.718282]], floatfmt=".4f"))
-    --  ------
-    pi  3.1416
-    e   2.7183
-    --  ------
+```pycon
+>>> print(tabulate([["pi",3.141593],["e",2.718282]], floatfmt=".4f"))
+--  ------
+pi  3.1416
+e   2.7183
+--  ------
+```
 
 `floatfmt` argument can be a list or a tuple of format strings, one per
 column, in which case every column may have different number formatting:
 
-    >>> print(tabulate([[0.12345, 0.12345, 0.12345]], floatfmt=(".1f", ".3f")))
-    ---  -----  -------
-    0.1  0.123  0.12345
-    ---  -----  -------
+```pycon
+>>> print(tabulate([[0.12345, 0.12345, 0.12345]], floatfmt=(".1f", ".3f")))
+---  -----  -------
+0.1  0.123  0.12345
+---  -----  -------
+```
+
+`intfmt` works similarly for integers
+
+    >>> print(tabulate([["a",1000],["b",90000]], intfmt=","))
+    -  ------
+    a   1,000
+    b  90,000
+    -  ------
 
 ### Text formatting
 
@@ -472,8 +742,10 @@ By default, `tabulate` removes leading and trailing whitespace from text
 columns. To disable whitespace removal, set the global module-level flag
 `PRESERVE_WHITESPACE`:
 
-    import tabulate
-    tabulate.PRESERVE_WHITESPACE = True
+```python
+import tabulate
+tabulate.PRESERVE_WHITESPACE = True
+```
 
 ### Wide (fullwidth CJK) symbols
 
@@ -482,15 +754,19 @@ fullwidth glyphs from Chinese, Japanese or Korean languages), the user
 should install `wcwidth` library. To install it together with
 `tabulate`:
 
-    pip install tabulate[widechars]
+```shell
+pip install tabulate[widechars]
+```
 
 Wide character support is enabled automatically if `wcwidth` library is
 already installed. To disable wide characters support without
 uninstalling `wcwidth`, set the global module-level flag
 `WIDE_CHARS_MODE`:
 
-    import tabulate
-    tabulate.WIDE_CHARS_MODE = False
+```python
+import tabulate
+tabulate.WIDE_CHARS_MODE = False
+```
 
 ### Multiline cells
 
@@ -512,131 +788,225 @@ formats may be ambiguous to the reader.
 The following examples of formatted output use the following table with
 a multiline cell, and headers with a multiline cell:
 
-    >>> table = [["eggs",451],["more\nspam",42]]
-    >>> headers = ["item\nname", "qty"]
+```pycon
+>>> table = [["eggs",451],["more\nspam",42]]
+>>> headers = ["item\nname", "qty"]
+```
 
 `plain` tables:
 
-    >>> print(tabulate(table, headers, tablefmt="plain"))
-    item      qty
-    name
-    eggs      451
-    more       42
-    spam
+```pycon
+>>> print(tabulate(table, headers, tablefmt="plain"))
+item      qty
+name
+eggs      451
+more       42
+spam
+```
 
 `simple` tables:
 
-    >>> print(tabulate(table, headers, tablefmt="simple"))
-    item      qty
-    name
-    ------  -----
-    eggs      451
-    more       42
-    spam
+```pycon
+>>> print(tabulate(table, headers, tablefmt="simple"))
+item      qty
+name
+------  -----
+eggs      451
+more       42
+spam
+```
 
 `grid` tables:
 
-    >>> print(tabulate(table, headers, tablefmt="grid"))
-    +--------+-------+
-    | item   |   qty |
-    | name   |       |
-    +========+=======+
-    | eggs   |   451 |
-    +--------+-------+
-    | more   |    42 |
-    | spam   |       |
-    +--------+-------+
+```pycon
+>>> print(tabulate(table, headers, tablefmt="grid"))
++--------+-------+
+| item   |   qty |
+| name   |       |
++========+=======+
+| eggs   |   451 |
++--------+-------+
+| more   |    42 |
+| spam   |       |
++--------+-------+
+```
 
 `fancy_grid` tables:
 
-    >>> print(tabulate(table, headers, tablefmt="fancy_grid"))
-    ╒════════╤═══════╕
-    │ item   │   qty │
-    │ name   │       │
-    ╞════════╪═══════╡
-    │ eggs   │   451 │
-    ├────────┼───────┤
-    │ more   │    42 │
-    │ spam   │       │
-    ╘════════╧═══════╛
+```pycon
+>>> print(tabulate(table, headers, tablefmt="fancy_grid"))
+╒════════╤═══════╕
+│ item   │   qty │
+│ name   │       │
+╞════════╪═══════╡
+│ eggs   │   451 │
+├────────┼───────┤
+│ more   │    42 │
+│ spam   │       │
+╘════════╧═══════╛
+```
 
 `pipe` tables:
 
-    >>> print(tabulate(table, headers, tablefmt="pipe"))
-    | item   |   qty |
-    | name   |       |
-    |:-------|------:|
-    | eggs   |   451 |
-    | more   |    42 |
-    | spam   |       |
+```pycon
+>>> print(tabulate(table, headers, tablefmt="pipe"))
+| item   |   qty |
+| name   |       |
+|:-------|------:|
+| eggs   |   451 |
+| more   |    42 |
+| spam   |       |
+```
 
 `orgtbl` tables:
 
-    >>> print(tabulate(table, headers, tablefmt="orgtbl"))
-    | item   |   qty |
-    | name   |       |
-    |--------+-------|
-    | eggs   |   451 |
-    | more   |    42 |
-    | spam   |       |
+```pycon
+>>> print(tabulate(table, headers, tablefmt="orgtbl"))
+| item   |   qty |
+| name   |       |
+|--------+-------|
+| eggs   |   451 |
+| more   |    42 |
+| spam   |       |
+```
 
 `jira` tables:
 
-    >>> print(tabulate(table, headers, tablefmt="jira"))
-    | item   |   qty |
-    | name   |       |
-    |:-------|------:|
-    | eggs   |   451 |
-    | more   |    42 |
-    | spam   |       |
+```pycon
+>>> print(tabulate(table, headers, tablefmt="jira"))
+| item   |   qty |
+| name   |       |
+|:-------|------:|
+| eggs   |   451 |
+| more   |    42 |
+| spam   |       |
+```
 
 `presto` tables:
 
-    >>> print(tabulate(table, headers, tablefmt="presto"))
-     item   |   qty
-     name   |
-    --------+-------
-     eggs   |   451
-     more   |    42
-     spam   |
+```pycon
+>>> print(tabulate(table, headers, tablefmt="presto"))
+ item   |   qty
+ name   |
+--------+-------
+ eggs   |   451
+ more   |    42
+ spam   |
+```
 
 `pretty` tables:
 
-    >>> print(tabulate(table, headers, tablefmt="pretty"))
-    +------+-----+
-    | item | qty |
-    | name |     |
-    +------+-----+
-    | eggs | 451 |
-    | more | 42  |
-    | spam |     |
-    +------+-----+
+```pycon
+>>> print(tabulate(table, headers, tablefmt="pretty"))
++------+-----+
+| item | qty |
+| name |     |
++------+-----+
+| eggs | 451 |
+| more | 42  |
+| spam |     |
++------+-----+
+```
 
 `psql` tables:
 
-    >>> print(tabulate(table, headers, tablefmt="psql"))
-    +--------+-------+
-    | item   |   qty |
-    | name   |       |
-    |--------+-------|
-    | eggs   |   451 |
-    | more   |    42 |
-    | spam   |       |
-    +--------+-------+
+```pycon
+>>> print(tabulate(table, headers, tablefmt="psql"))
++--------+-------+
+| item   |   qty |
+| name   |       |
+|--------+-------|
+| eggs   |   451 |
+| more   |    42 |
+| spam   |       |
++--------+-------+
+```
 
 `rst` tables:
 
-    >>> print(tabulate(table, headers, tablefmt="rst"))
-    ======  =====
-    item      qty
-    name
-    ======  =====
-    eggs      451
-    more       42
-    spam
-    ======  =====
+```pycon
+>>> print(tabulate(table, headers, tablefmt="rst"))
+======  =====
+item      qty
+name
+======  =====
+eggs      451
+more       42
+spam
+======  =====
+```
+
+Multiline cells are not well-supported for the other table formats.
+
+### Automating Multilines
+While tabulate supports data passed in with multilines entries explicitly provided,
+it also provides some support to help manage this work internally.
+
+The `maxcolwidths` argument is a list where each entry specifies the max width for
+it's respective column. Any cell that will exceed this will automatically wrap the content.
+To assign the same max width for all columns, a singular int scaler can be used.
+
+Use `None` for any columns where an explicit maximum does not need to be provided,
+and thus no automate multiline wrapping will take place.
+
+The wrapping uses the python standard [textwrap.wrap](https://docs.python.org/3/library/textwrap.html#textwrap.wrap)
+function with default parameters - aside from width.
+
+This example demonstrates usage of automatic multiline wrapping, though typically
+the lines being wrapped would probably be significantly longer than this.
+
+```pycon
+>>> print(tabulate([["John Smith", "Middle Manager"]], headers=["Name", "Title"], tablefmt="grid", maxcolwidths=[None, 8]))
++------------+---------+
+| Name       | Title   |
++============+=========+
+| John Smith | Middle  |
+|            | Manager |
++------------+---------+
+```
+
+### Adding Separating lines
+One might want to add one or more separating lines to highlight different sections in a table.
+
+The separating lines will be of the same type as the one defined by the specified formatter as either the
+linebetweenrows, linebelowheader, linebelow, lineabove or just a simple empty line when none is defined for the formatter
+
+
+    >>> from tabulate import tabulate, SEPARATING_LINE
+
+    table = [["Earth",6371],
+             ["Mars",3390],
+             SEPARATING_LINE,
+             ["Moon",1737]]
+    print(tabulate(table, tablefmt="simple"))
+    -----  ----
+    Earth  6371
+    Mars   3390
+    -----  ----
+    Moon   1737
+    -----  ----
+
+### ANSI support
+ANSI escape codes are non-printable byte sequences usually used for terminal operations like setting
+color output or modifying cursor positions. Because multi-byte ANSI sequences are inherently non-printable,
+they can still introduce unwanted extra length to strings. For example:
+
+    >>> len('\033[31mthis text is red\033[0m')  # printable length is 16
+    25
+
+To deal with this, string lengths are calculated after first removing all ANSI escape sequences. This ensures
+that the actual printable length is used for column widths, rather than the byte length. In the final, printable
+table, however, ANSI escape sequences are not removed so the original styling is preserved.
+
+Some terminals support a special grouping of ANSI escape sequences that are intended to display hyperlinks
+much in the same way they are shown in browsers. These are handled just as mentioned before: non-printable
+ANSI escape sequences are removed prior to string length calculation. The only diifference with escaped
+hyperlinks is that column width will be based on the length of the URL _text_ rather than the URL
+itself (terminals would show this text). For example:
+
+    >>> len('\x1b]8;;https://example.com\x1b\\example\x1b]8;;\x1b\\')  # display length is 7, showing 'example'
+    45
 
-Multiline cells are not well supported for the other table formats.
 
 Usage of the command line utility
 ---------------------------------
@@ -653,6 +1023,7 @@ Usage of the command line utility
     -o FILE, --output FILE    print table to FILE (default: stdout)
     -s REGEXP, --sep REGEXP   use a custom column separator (default: whitespace)
     -F FPFMT, --float FPFMT   floating point number format (default: g)
+    -I INTFMT, --int INTFMT   integer point number format (default: "")
     -f FMT, --format FMT      set output table format; supported formats:
                               plain, simple, github, grid, fancy_grid, pipe,
                               orgtbl, rst, mediawiki, html, latex, latex_raw,
@@ -679,18 +1050,18 @@ 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.3 in Windows 10 x64:
+was run in Python 3.9.13 on Windows 10:
 
     =================================  ==========  ===========
     Table formatter                      time, μs    rel. time
     =================================  ==========  ===========
     csv to StringIO                          12.5          1.0
-    join with tabs and newlines              15.6          1.3
-    asciitable (0.8.0)                      191.4         15.4
-    tabulate (0.8.9)                        472.8         38.0
-    tabulate (0.8.9, WIDE_CHARS_MODE)       789.6         63.4
-    PrettyTable (0.7.2)                     879.1         70.6
-    texttable (1.6.2)                      1352.2        108.6
+    join with tabs and newlines              14.6          1.2
+    asciitable (0.8.0)                      192.0         15.4
+    tabulate (0.9.0)                        483.5         38.7
+    tabulate (0.9.0, WIDE_CHARS_MODE)       637.6         51.1
+    PrettyTable (3.4.1)                    1080.6         86.6
+    texttable (1.6.4)                      1390.3        111.4
     =================================  ==========  ===========
 
 
@@ -715,28 +1086,43 @@ To run tests on all supported Python versions, make sure all Python
 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`,
-`python2.7`, `python3.4` etc. On Windows it looks for
-`C:\Python26\python.exe`, `C:\Python27\python.exe` and
-`C:\Python34\python.exe` respectively.
+On Linux `tox` expects to find executables like `python3.11`, `python3.12` etc.
+On Windows it looks for `C:\Python311\python.exe`, `C:\Python312\python.exe` etc. respectively.
+
+One way to install all the required versions of the Python interpreter is to use [pyenv](https://github.com/pyenv/pyenv).
+All versions can then be easily installed with something like:
+
+     pyenv install 3.11.7
+     pyenv install 3.12.1
+     ...
+
+Don't forget to change your `PATH` so that `tox` knows how to find all the installed versions. Something like
+
+     export PATH="${PATH}:${HOME}/.pyenv/shims"
 
 To test only some Python environments, use `-e` option. For example, to
-test only against Python 2.7 and Python 3.8, run:
+test only against Python 3.11 and Python 3.12, run:
 
-    tox -e py27,py38
+```shell
+tox -e py311,py312
+```
 
 in the root of the project source tree.
 
 To enable NumPy and Pandas tests, run:
 
-    tox -e py27-extra,py38-extra
+```shell
+tox -e py311-extra,py312-extra
+```
 
 (this may take a long time the first time, because NumPy and Pandas will
 have to be installed in the new virtual environments)
 
 To fix code formatting:
 
-    tox -e lint
+```shell
+tox -e lint
+```
 
 See `tox.ini` file to learn how to use to test
 individual Python versions.
@@ -758,4 +1144,9 @@ 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, Vilhelm Prytz.
+Bart Broere, Vilhelm Prytz, Alexander Gažo, Hugo van Kemenade,
+jamescooke, Matt Warner, Jérôme Provensal, Kevin Deldycke,
+Kian-Meng Ang, Kevin Patterson, Shodhan Save, cleoold, KOLANICH,
+Vijaya Krishna Kasula, Furcy Pin, Christian Fibich, Shaun Duncan,
+Dimitri Papadopoulos, Élie Goudout.
+
diff --git a/appveyor.yml b/appveyor.yml
index 0055e96..12bd772 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -1,3 +1,4 @@
+image: Visual Studio 2022
 environment:
 
   matrix:
@@ -7,29 +8,27 @@ environment:
     # The list here is complete (excluding Python 2.6, which
     # isn't covered by this document) at the time of writing.
 
-    - PYTHON: "C:\\Python27"
-    - PYTHON: "C:\\Python35"
-    - PYTHON: "C:\\Python36"
-    - PYTHON: "C:\\Python37"
-    - PYTHON: "C:\\Python38"
-    - PYTHON: "C:\\Python27-x64"
-    - PYTHON: "C:\\Python35-x64"
-    - PYTHON: "C:\\Python36-x64"
-    - PYTHON: "C:\\Python37-x64"
-    - PYTHON: "C:\\Python38-x64"
+    #- PYTHON: "C:\\Python39"
+    #- PYTHON: "C:\\Python310"
+    #- PYTHON: "C:\\Python311"
+    #- PYTHON: "C:\\Python312"
+    - PYTHON: "C:\\Python39-x64"
+    - PYTHON: "C:\\Python310-x64"
+    - PYTHON: "C:\\Python311-x64"
+    - PYTHON: "C:\\Python312-x64"
 
 install:
+  # Newer setuptools is needed for proper support of pyproject.toml
+  - "%PYTHON%\\python.exe -m pip install setuptools --upgrade"
   # We need wheel installed to build wheels
-  - "%PYTHON%\\python.exe -m pip install wheel"
+  - "%PYTHON%\\python.exe -m pip install wheel --upgrade"
+  - "%PYTHON%\\python.exe -m pip install build setuptools_scm"
   - "%PYTHON%\\python.exe -m pip install pytest numpy pandas"
 
 build: off
 
 test_script:
   # Put your test command here.
-  # If you don't need to build C extensions on 64-bit Python 3.3 or 3.4,
-  # you can remove "build.cmd" from the front of the command, as it's
-  # only needed to support those cases.
   # Note that you must use the environment variable %PYTHON% to refer to
   # the interpreter you're using - Appveyor does not do anything special
   # to put the Python version you want to use on PATH.
@@ -38,11 +37,9 @@ test_script:
 
 after_test:
   # This step builds your wheels.
-  # Again, you only need build.cmd if you're building C extensions for
-  # 64-bit Python 3.3/3.4. And you need to use %PYTHON% to get the correct
-  # interpreter
+  # Again, you need to use %PYTHON% to get the correct interpreter
   #- "build.cmd %PYTHON%\\python.exe setup.py bdist_wheel"
-  - "%PYTHON%\\python.exe setup.py sdist bdist_wheel"
+  - "%PYTHON%\\python.exe -m build -nswx ."
 
 artifacts:
   # bdist_wheel puts your built wheel in the dist directory
diff --git a/benchmark.py b/benchmark.py
index b79c47b..8422f5c 100644
--- a/benchmark.py
+++ b/benchmark.py
@@ -1,35 +1,19 @@
-# -*- coding: utf-8 -*-
-from __future__ import unicode_literals
-from __future__ import print_function
 from timeit import timeit
 import tabulate
 import asciitable
 import prettytable
 import texttable
 import sys
-import codecs
-from platform import python_version_tuple
 
 setup_code = r"""
 from csv import writer
-try: # Python 2
-    from StringIO import StringIO
-except: # Python 3
-    from io import StringIO
+from io import StringIO
 import tabulate
 import asciitable
 import prettytable
 import texttable
 
 
-import platform
-if platform.platform().startswith("Windows") \
-   and \
-   platform.python_version_tuple() < ('3','6','0'):
-    import win_unicode_console
-    win_unicode_console.enable()
-
-
 table=[["some text"]+list(range(i,i+9)) for i in range(10)]
 
 
@@ -108,14 +92,7 @@ def benchmark(n):
         results, ["Table formatter", "time, μs", "rel. time"], "rst", floatfmt=".1f"
     )
 
-    from platform import platform
-
-    if platform().startswith("Windows"):
-        print(table)
-    elif python_version_tuple()[0] < "3":
-        print(codecs.encode(table, "utf-8"))
-    else:
-        print(table)
+    print(table)
 
 
 if __name__ == "__main__":
diff --git a/pyproject.toml b/pyproject.toml
new file mode 100644
index 0000000..8cb6216
--- /dev/null
+++ b/pyproject.toml
@@ -0,0 +1,38 @@
+[build-system]
+requires = ["setuptools>=61.2.0", "setuptools_scm[toml]>=3.4.3"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "tabulate"
+authors = [{name = "Sergey Astanin", email = "s.astanin@gmail.com"}]
+license = {text = "MIT"}
+description = "Pretty-print tabular data"
+readme = "README.md"
+classifiers = [
+    "Development Status :: 4 - Beta",
+    "License :: OSI Approved :: MIT License",
+    "Operating System :: OS Independent",
+    "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.9",
+    "Programming Language :: Python :: 3.10",
+    "Programming Language :: Python :: 3.11",
+    "Programming Language :: Python :: 3.12",
+    "Topic :: Software Development :: Libraries",
+]
+requires-python = ">=3.9"
+dynamic = ["version"]
+
+[project.urls]
+Homepage = "https://github.com/astanin/python-tabulate"
+
+[project.optional-dependencies]
+widechars = ["wcwidth"]
+
+[project.scripts]
+tabulate = "tabulate:_main"
+
+[tool.setuptools]
+packages = ["tabulate"]
+
+[tool.setuptools_scm]
+write_to = "tabulate/version.py"
diff --git a/setup.py b/setup.py
deleted file mode 100644
index 1cb6a84..0000000
--- a/setup.py
+++ /dev/null
@@ -1,66 +0,0 @@
-#!/usr/bin/env python
-
-try:
-    from setuptools import setup
-except ImportError:
-    from distutils.core import setup
-
-
-from platform import python_version_tuple, python_implementation
-import os
-import re
-
-# strip links from the descripton on the PyPI
-if python_version_tuple()[0] >= "3":
-    LONG_DESCRIPTION = open("README.md", "r", encoding="utf-8").read()
-else:
-    LONG_DESCRIPTION = open("README.md", "r").read()
-
-# strip Build Status from the PyPI package
-try:
-    if python_version_tuple()[:2] >= ("2", "7"):
-        status_re = "^Build status\n(.*\n){7}"
-        LONG_DESCRIPTION = re.sub(status_re, "", LONG_DESCRIPTION, flags=re.M)
-except TypeError:
-    if python_implementation() == "IronPython":
-        # IronPython doesn't support flags in re.sub (IronPython issue #923)
-        pass
-    else:
-        raise
-
-install_options = os.environ.get("TABULATE_INSTALL", "").split(",")
-libonly_flags = set(["lib-only", "libonly", "no-cli", "without-cli"])
-if libonly_flags.intersection(install_options):
-    console_scripts = []
-else:
-    console_scripts = ["tabulate = tabulate:_main"]
-
-
-setup(
-    name="tabulate",
-    version="0.8.10",
-    description="Pretty-print tabular data",
-    long_description=LONG_DESCRIPTION,
-    long_description_content_type="text/markdown",
-    author="Sergey Astanin",
-    author_email="s.astanin@gmail.com",
-    url="https://github.com/astanin/python-tabulate",
-    license="MIT",
-    classifiers=[
-        "Development Status :: 4 - Beta",
-        "License :: OSI Approved :: MIT License",
-        "Operating System :: OS Independent",
-        "Programming Language :: Python :: 2",
-        "Programming Language :: Python :: 2.7",
-        "Programming Language :: Python :: 3",
-        "Programming Language :: Python :: 3.5",
-        "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"],
-    entry_points={"console_scripts": console_scripts},
-    extras_require={"widechars": ["wcwidth"]},
-)
diff --git a/tabulate.py b/tabulate/__init__.py
similarity index 51%
rename from tabulate.py
rename to tabulate/__init__.py
index 55b896e..63d8d32 100644
--- a/tabulate.py
+++ b/tabulate/__init__.py
@@ -1,77 +1,40 @@
-# -*- coding: utf-8 -*-
-
 """Pretty-print tabular data."""
 
-from __future__ import print_function
-from __future__ import unicode_literals
+import warnings
 from collections import namedtuple
-import sys
+from collections.abc import Iterable, Sized
+from html import escape as htmlescape
+from itertools import chain, zip_longest as izip_longest
+from functools import reduce, partial
+import io
 import re
 import math
-
-
-if sys.version_info >= (3, 3):
-    from collections.abc import Iterable
-else:
-    from collections import Iterable
-
-if sys.version_info[0] < 3:
-    from itertools import izip_longest
-    from functools import partial
-
-    _none_type = type(None)
-    _bool_type = bool
-    _int_type = int
-    _long_type = long  # noqa
-    _float_type = float
-    _text_type = unicode  # noqa
-    _binary_type = str
-
-    def _is_file(f):
-        return hasattr(f, "read")
-
-
-else:
-    from itertools import zip_longest as izip_longest
-    from functools import reduce, partial
-
-    _none_type = type(None)
-    _bool_type = bool
-    _int_type = int
-    _long_type = int
-    _float_type = float
-    _text_type = str
-    _binary_type = bytes
-    basestring = str
-
-    import io
-
-    def _is_file(f):
-        return isinstance(f, io.IOBase)
-
+import textwrap
+import dataclasses
+import sys
 
 try:
     import wcwidth  # optional wide-character (CJK) support
 except ImportError:
     wcwidth = None
 
-try:
-    from html import escape as htmlescape
-except ImportError:
-    from cgi import escape as htmlescape
+
+def _is_file(f):
+    return isinstance(f, io.IOBase)
 
 
 __all__ = ["tabulate", "tabulate_formats", "simple_separated_format"]
-__version__ = "0.8.10"
+try:
+    from .version import version as __version__  # noqa: F401
+except ImportError:
+    pass  # running __init__.py as a script, AppVeyor pytests
 
 
 # minimum extra space in headers
 MIN_PADDING = 2
 
-# Whether or not to preserve leading/trailing whitespace in data.
-PRESERVE_WHITESPACE = False
-
 _DEFAULT_FLOATFMT = "g"
+_DEFAULT_INTFMT = ""
 _DEFAULT_MISSINGVAL = ""
 # default align will be overwritten by "left", "center" or "decimal"
 # depending on the formatter
@@ -81,6 +44,9 @@ def _is_file(f):
 # if True, enable wide-character (CJK) support
 WIDE_CHARS_MODE = wcwidth is not None
 
+# Constant that can be used as part of passed rows to generate a separating line
+# It is purposely an unprintable character, very unlikely to be used in a table
+SEPARATING_LINE = "\001"
 
 Line = namedtuple("Line", ["begin", "hline", "sep", "end"])
 
@@ -88,7 +54,7 @@ def _is_file(f):
 DataRow = namedtuple("DataRow", ["begin", "sep", "end"])
 
 
-# A table structure is suppposed to be:
+# A table structure is supposed to be:
 #
 #     --- lineabove ---------
 #         headerrow
@@ -134,6 +100,20 @@ def _is_file(f):
 )
 
 
+def _is_separating_line_value(value):
+    return type(value) is str and value.strip() == SEPARATING_LINE
+
+
+def _is_separating_line(row):
+    row_type = type(row)
+    is_sl = (row_type == list or row_type == str) and (
+        (len(row) >= 1 and _is_separating_line_value(row[0]))
+        or (len(row) >= 2 and _is_separating_line_value(row[1]))
+    )
+
+    return is_sl
+
+
 def _pipe_segment_with_colons(align, colwidth):
     """Return a segment of a horizontal line with optional colons which
     indicate column's alignment (as in `pipe` output format)."""
@@ -157,12 +137,36 @@ def _pipe_line_with_colons(colwidths, colaligns):
     return "|" + "|".join(segments) + "|"
 
 
+def _grid_segment_with_colons(colwidth, align):
+    """Return a segment of a horizontal line with optional colons which indicate
+    column's alignment in a grid table."""
+    width = colwidth
+    if align == "right":
+        return ("=" * (width - 1)) + ":"
+    elif align == "center":
+        return ":" + ("=" * (width - 2)) + ":"
+    elif align == "left":
+        return ":" + ("=" * (width - 1))
+    else:
+        return "=" * width
+
+
+def _grid_line_with_colons(colwidths, colaligns):
+    """Return a horizontal line with optional colons to indicate column's alignment
+    in a grid table."""
+    if not colaligns:
+        colaligns = [""] * len(colwidths)
+    segments = [_grid_segment_with_colons(w, a) for a, w in zip(colaligns, colwidths)]
+    return "+" + "+".join(segments) + "+"
+
+
+
 def _mediawiki_row_with_attrs(separator, cell_values, colwidths, colaligns):
     alignment = {
         "left": "",
-        "right": 'align="right"| ',
-        "center": 'align="center"| ',
-        "decimal": 'align="right"| ',
+        "right": 'style="text-align: right;"| ',
+        "center": 'style="text-align: center;"| ',
+        "decimal": 'style="text-align: right;"| ',
     }
     # hard-coded padding _around_ align attribute and value together
     # rather than padding parameter which affects only the value
@@ -204,7 +208,7 @@ def _html_row_with_attrs(celltag, unsafe, cell_values, colwidths, 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)
+        rowhtml = f"<table>\n<thead>\n{rowhtml}\n</thead>\n<tbody>"
     return rowhtml
 
 
@@ -216,7 +220,7 @@ def _moin_row_with_attrs(celltag, cell_values, colwidths, colaligns, header=""):
         "decimal": '<style="text-align: right;">',
     }
     values_with_attrs = [
-        "{0}{1} {2} ".format(celltag, alignment.get(a, ""), header + c + header)
+        "{}{} {} ".format(celltag, alignment.get(a, ""), header + c + header)
         for c, a in zip(cell_values, colaligns)
     ]
     return "".join(values_with_attrs) + "||"
@@ -235,6 +239,59 @@ def _latex_line_begin_tabular(colwidths, colaligns, booktabs=False, longtable=Fa
     )
 
 
+def _asciidoc_row(is_header, *args):
+    """handle header and data rows for asciidoc format"""
+
+    def make_header_line(is_header, colwidths, colaligns):
+        # generate the column specifiers
+
+        alignment = {"left": "<", "right": ">", "center": "^", "decimal": ">"}
+        # use the column widths generated by tabulate for the asciidoc column width specifiers
+        asciidoc_alignments = zip(
+            colwidths, [alignment[colalign] for colalign in colaligns]
+        )
+        asciidoc_column_specifiers = [
+            f"{width:d}{align}" for width, align in asciidoc_alignments
+        ]
+        header_list = ['cols="' + (",".join(asciidoc_column_specifiers)) + '"']
+
+        # generate the list of options (currently only "header")
+        options_list = []
+
+        if is_header:
+            options_list.append("header")
+
+        if options_list:
+            header_list += ['options="' + ",".join(options_list) + '"']
+
+        # generate the list of entries in the table header field
+
+        return "[{}]\n|====".format(",".join(header_list))
+
+    if len(args) == 2:
+        # two arguments are passed if called in the context of aboveline
+        # print the table header with column widths and optional header tag
+        return make_header_line(False, *args)
+
+    elif len(args) == 3:
+        # three arguments are passed if called in the context of dataline or headerline
+        # print the table line and make the aboveline if it is a header
+
+        cell_values, colwidths, colaligns = args
+        data_line = "|" + "|".join(cell_values)
+
+        if is_header:
+            return make_header_line(True, colwidths, colaligns) + "\n" + data_line
+        else:
+            return data_line
+
+    else:
+        raise ValueError(
+            " _asciidoc_row() requires two (colwidths, colaligns) "
+            + "or three (cell_values, colwidths, colaligns) arguments) "
+        )
+
+
 LATEX_ESCAPE_RULES = {
     r"&": r"\&",
     r"%": r"\%",
@@ -262,7 +319,7 @@ def escape_char(c):
 
 def _rst_escape_first_column(rows, headers):
     def escape_empty(val):
-        if isinstance(val, (_text_type, _binary_type)) and not val.strip():
+        if isinstance(val, (str, bytes)) and not val.strip():
             return ".."
         else:
             return val
@@ -310,6 +367,56 @@ def escape_empty(val):
         padding=1,
         with_header_hide=None,
     ),
+    "simple_grid": TableFormat(
+        lineabove=Line("┌", "─", "┬", "┐"),
+        linebelowheader=Line("├", "─", "┼", "┤"),
+        linebetweenrows=Line("├", "─", "┼", "┤"),
+        linebelow=Line("└", "─", "┴", "┘"),
+        headerrow=DataRow("│", "│", "│"),
+        datarow=DataRow("│", "│", "│"),
+        padding=1,
+        with_header_hide=None,
+    ),
+    "rounded_grid": TableFormat(
+        lineabove=Line("╭", "─", "┬", "╮"),
+        linebelowheader=Line("├", "─", "┼", "┤"),
+        linebetweenrows=Line("├", "─", "┼", "┤"),
+        linebelow=Line("╰", "─", "┴", "╯"),
+        headerrow=DataRow("│", "│", "│"),
+        datarow=DataRow("│", "│", "│"),
+        padding=1,
+        with_header_hide=None,
+    ),
+    "heavy_grid": TableFormat(
+        lineabove=Line("┏", "━", "┳", "┓"),
+        linebelowheader=Line("┣", "━", "╋", "┫"),
+        linebetweenrows=Line("┣", "━", "╋", "┫"),
+        linebelow=Line("┗", "━", "┻", "┛"),
+        headerrow=DataRow("┃", "┃", "┃"),
+        datarow=DataRow("┃", "┃", "┃"),
+        padding=1,
+        with_header_hide=None,
+    ),
+    "mixed_grid": TableFormat(
+        lineabove=Line("┍", "━", "┯", "┑"),
+        linebelowheader=Line("┝", "━", "┿", "┥"),
+        linebetweenrows=Line("├", "─", "┼", "┤"),
+        linebelow=Line("┕", "━", "┷", "┙"),
+        headerrow=DataRow("│", "│", "│"),
+        datarow=DataRow("│", "│", "│"),
+        padding=1,
+        with_header_hide=None,
+    ),
+    "double_grid": TableFormat(
+        lineabove=Line("╔", "═", "╦", "╗"),
+        linebelowheader=Line("╠", "═", "╬", "╣"),
+        linebetweenrows=Line("╠", "═", "╬", "╣"),
+        linebelow=Line("╚", "═", "╩", "╝"),
+        headerrow=DataRow("║", "║", "║"),
+        datarow=DataRow("║", "║", "║"),
+        padding=1,
+        with_header_hide=None,
+    ),
     "fancy_grid": TableFormat(
         lineabove=Line("╒", "═", "╤", "╕"),
         linebelowheader=Line("╞", "═", "╪", "╡"),
@@ -320,6 +427,76 @@ def escape_empty(val):
         padding=1,
         with_header_hide=None,
     ),
+    "colon_grid": TableFormat(
+        lineabove=Line("+", "-", "+", "+"),
+        linebelowheader=_grid_line_with_colons,
+        linebetweenrows=Line("+", "-", "+", "+"),
+        linebelow=Line("+", "-", "+", "+"),
+        headerrow=DataRow("|", "|", "|"),
+        datarow=DataRow("|", "|", "|"),
+        padding=1,
+        with_header_hide=None,
+    ),
+    "outline": TableFormat(
+        lineabove=Line("+", "-", "+", "+"),
+        linebelowheader=Line("+", "=", "+", "+"),
+        linebetweenrows=None,
+        linebelow=Line("+", "-", "+", "+"),
+        headerrow=DataRow("|", "|", "|"),
+        datarow=DataRow("|", "|", "|"),
+        padding=1,
+        with_header_hide=None,
+    ),
+    "simple_outline": TableFormat(
+        lineabove=Line("┌", "─", "┬", "┐"),
+        linebelowheader=Line("├", "─", "┼", "┤"),
+        linebetweenrows=None,
+        linebelow=Line("└", "─", "┴", "┘"),
+        headerrow=DataRow("│", "│", "│"),
+        datarow=DataRow("│", "│", "│"),
+        padding=1,
+        with_header_hide=None,
+    ),
+    "rounded_outline": TableFormat(
+        lineabove=Line("╭", "─", "┬", "╮"),
+        linebelowheader=Line("├", "─", "┼", "┤"),
+        linebetweenrows=None,
+        linebelow=Line("╰", "─", "┴", "╯"),
+        headerrow=DataRow("│", "│", "│"),
+        datarow=DataRow("│", "│", "│"),
+        padding=1,
+        with_header_hide=None,
+    ),
+    "heavy_outline": TableFormat(
+        lineabove=Line("┏", "━", "┳", "┓"),
+        linebelowheader=Line("┣", "━", "╋", "┫"),
+        linebetweenrows=None,
+        linebelow=Line("┗", "━", "┻", "┛"),
+        headerrow=DataRow("┃", "┃", "┃"),
+        datarow=DataRow("┃", "┃", "┃"),
+        padding=1,
+        with_header_hide=None,
+    ),
+    "mixed_outline": TableFormat(
+        lineabove=Line("┍", "━", "┯", "┑"),
+        linebelowheader=Line("┝", "━", "┿", "┥"),
+        linebetweenrows=None,
+        linebelow=Line("┕", "━", "┷", "┙"),
+        headerrow=DataRow("│", "│", "│"),
+        datarow=DataRow("│", "│", "│"),
+        padding=1,
+        with_header_hide=None,
+    ),
+    "double_outline": TableFormat(
+        lineabove=Line("╔", "═", "╦", "╗"),
+        linebelowheader=Line("╠", "═", "╬", "╣"),
+        linebetweenrows=None,
+        linebelow=Line("╚", "═", "╩", "╝"),
+        headerrow=DataRow("║", "║", "║"),
+        datarow=DataRow("║", "║", "║"),
+        padding=1,
+        with_header_hide=None,
+    ),
     "fancy_outline": TableFormat(
         lineabove=Line("╒", "═", "╤", "╕"),
         linebelowheader=Line("╞", "═", "╪", "╡"),
@@ -525,6 +702,16 @@ def escape_empty(val):
         padding=1,
         with_header_hide=None,
     ),
+    "asciidoc": TableFormat(
+        lineabove=partial(_asciidoc_row, False),
+        linebelowheader=None,
+        linebetweenrows=None,
+        linebelow=Line("|====", "", "", ""),
+        headerrow=partial(_asciidoc_row, True),
+        datarow=partial(_asciidoc_row, False),
+        padding=1,
+        with_header_hide=["lineabove"],
+    ),
 }
 
 
@@ -537,7 +724,13 @@ def escape_empty(val):
     "plain": "plain",
     "simple": "simple",
     "grid": "grid",
+    "simple_grid": "simple_grid",
+    "rounded_grid": "rounded_grid",
+    "heavy_grid": "heavy_grid",
+    "mixed_grid": "mixed_grid",
+    "double_grid": "double_grid",
     "fancy_grid": "fancy_grid",
+    "colon_grid": "colon_grid",
     "pipe": "pipe",
     "orgtbl": "orgtbl",
     "jira": "jira",
@@ -545,6 +738,13 @@ def escape_empty(val):
     "pretty": "pretty",
     "psql": "psql",
     "rst": "rst",
+    "outline": "outline",
+    "simple_outline": "simple_outline",
+    "rounded_outline": "rounded_outline",
+    "heavy_outline": "heavy_outline",
+    "mixed_outline": "mixed_outline",
+    "double_outline": "double_outline",
+    "fancy_outline": "fancy_outline",
 }
 
 # TODO: Add multiline support for the remaining table formats:
@@ -559,15 +759,60 @@ def escape_empty(val):
 
 _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|\x1b\]8;;(.*?)\x1b\\"
-)  # ANSI color codes
-_invisible_codes_bytes = re.compile(
-    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
+
+# Handle ANSI escape sequences for both control sequence introducer (CSI) and
+# operating system command (OSC). Both of these begin with 0x1b (or octal 033),
+# which will be shown below as ESC.
+#
+# CSI ANSI escape codes have the following format, defined in section 5.4 of ECMA-48:
+#
+# CSI: ESC followed by the '[' character (0x5b)
+# Parameter Bytes: 0..n bytes in the range 0x30-0x3f
+# Intermediate Bytes: 0..n bytes in the range 0x20-0x2f
+# Final Byte: a single byte in the range 0x40-0x7e
+#
+# Also include the terminal hyperlink sequences as described here:
+# https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
+#
+# OSC 8 ; params ; uri ST display_text OSC 8 ;; ST
+#
+# Example: \x1b]8;;https://example.com\x5ctext to show\x1b]8;;\x5c
+#
+# Where:
+# OSC: ESC followed by the ']' character (0x5d)
+# params: 0..n optional key value pairs separated by ':' (e.g. foo=bar:baz=qux:abc=123)
+# URI: the actual URI with protocol scheme (e.g. https://, file://, ftp://)
+# ST: ESC followed by the '\' character (0x5c)
+_esc = r"\x1b"
+_csi = rf"{_esc}\["
+_osc = rf"{_esc}\]"
+_st = rf"{_esc}\\"
+
+_ansi_escape_pat = rf"""
+    (
+        # terminal colors, etc
+        {_csi}        # CSI
+        [\x30-\x3f]*  # parameter bytes
+        [\x20-\x2f]*  # intermediate bytes
+        [\x40-\x7e]   # final byte
+    |
+        # terminal hyperlinks
+        {_osc}8;        # OSC opening
+        (\w+=\w+:?)*    # key=value params list (submatch 2)
+        ;               # delimiter
+        ([^{_esc}]+)    # URI - anything but ESC (submatch 3)
+        {_st}           # ST
+        ([^{_esc}]+)    # link text - anything but ESC (submatch 4)
+        {_osc}8;;{_st}  # "closing" OSC sequence
+    )
+"""
+_ansi_codes = re.compile(_ansi_escape_pat, re.VERBOSE)
+_ansi_codes_bytes = re.compile(_ansi_escape_pat.encode("utf8"), re.VERBOSE)
+_ansi_color_reset_code = "\033[0m"
+
+_float_with_thousands_separators = re.compile(
+    r"^(([+-]?[0-9]{1,3})(?:,([0-9]{3}))*)?(?(1)\.[0-9]*|\.[0-9]+)?$"
+)
 
 
 def simple_separated_format(separator):
@@ -590,6 +835,39 @@ def simple_separated_format(separator):
     )
 
 
+def _isnumber_with_thousands_separator(string):
+    """
+    >>> _isnumber_with_thousands_separator(".")
+    False
+    >>> _isnumber_with_thousands_separator("1")
+    True
+    >>> _isnumber_with_thousands_separator("1.")
+    True
+    >>> _isnumber_with_thousands_separator(".1")
+    True
+    >>> _isnumber_with_thousands_separator("1000")
+    False
+    >>> _isnumber_with_thousands_separator("1,000")
+    True
+    >>> _isnumber_with_thousands_separator("1,0000")
+    False
+    >>> _isnumber_with_thousands_separator("1,000.1234")
+    True
+    >>> _isnumber_with_thousands_separator(b"1,000.1234")
+    True
+    >>> _isnumber_with_thousands_separator("+1,000.1234")
+    True
+    >>> _isnumber_with_thousands_separator("-1,000.1234")
+    True
+    """
+    try:
+        string = string.decode()
+    except (UnicodeDecodeError, AttributeError):
+        pass
+
+    return bool(re.match(_float_with_thousands_separators, string))
+
+
 def _isconvertible(conv, string):
     try:
         conv(string)
@@ -613,7 +891,7 @@ def _isnumber(string):
     """
     if not _isconvertible(float, string):
         return False
-    elif isinstance(string, (_text_type, _binary_type)) and (
+    elif isinstance(string, (str, bytes)) and (
         math.isinf(float(string)) or math.isnan(float(string))
     ):
         return string.lower() in ["inf", "-inf", "nan"]
@@ -629,8 +907,13 @@ def _isint(string, inttype=int):
     """
     return (
         type(string) is inttype
-        or (isinstance(string, _binary_type) or isinstance(string, _text_type))
-        and _isconvertible(inttype, string)
+        or (
+            (hasattr(string, "is_integer") or hasattr(string, "__array__"))
+            and str(type(string)).startswith("<class 'numpy.int")
+        )  # numpy.int64 and similar
+        or (
+            isinstance(string, (bytes, str)) and _isconvertible(inttype, string)
+        )  # integer as string
     )
 
 
@@ -643,8 +926,8 @@ def _isbool(string):
     >>> _isbool(1)
     False
     """
-    return type(string) is _bool_type or (
-        isinstance(string, (_binary_type, _text_type)) and string in ("True", "False")
+    return type(string) is bool or (
+        isinstance(string, (bytes, str)) and string in ("True", "False")
     )
 
 
@@ -664,27 +947,23 @@ def _type(string, has_invisible=True, numparse=True):
 
     """
 
-    if has_invisible and (
-        isinstance(string, _text_type) or isinstance(string, _binary_type)
-    ):
-        string = _strip_invisible(string)
+    if has_invisible and isinstance(string, (str, bytes)):
+        string = _strip_ansi(string)
 
     if string is None:
-        return _none_type
+        return type(None)
     elif hasattr(string, "isoformat"):  # datetime.datetime, date, and time
-        return _text_type
+        return str
     elif _isbool(string):
-        return _bool_type
+        return bool
     elif _isint(string) and numparse:
         return int
-    elif _isint(string, _long_type) and numparse:
-        return int
     elif _isnumber(string) and numparse:
         return float
-    elif isinstance(string, _binary_type):
-        return _binary_type
+    elif isinstance(string, bytes):
+        return bytes
     else:
-        return _text_type
+        return str
 
 
 def _afterpoint(string):
@@ -698,9 +977,11 @@ def _afterpoint(string):
     -1
     >>> _afterpoint("123e45")
     2
+    >>> _afterpoint("123,456.78")
+    2
 
     """
-    if _isnumber(string):
+    if _isnumber(string) or _isnumber_with_thousands_separator(string):
         if _isint(string):
             return -1
         else:
@@ -751,18 +1032,24 @@ def _padnone(ignore_width, s):
     return s
 
 
-def _strip_invisible(s):
-    r"""Remove invisible ANSI color codes.
+def _strip_ansi(s):
+    r"""Remove ANSI escape sequences, both CSI (color codes, etc) and OSC hyperlinks.
+
+    CSI sequences are simply removed from the output, while OSC hyperlinks are replaced
+    with the link text. Note: it may be desirable to show the URI instead but this is not
+    supported.
 
-    >>> str(_strip_invisible('\x1B]8;;https://example.com\x1B\\This is a link\x1B]8;;\x1B\\'))
-    'This is a link'
+    >>> repr(_strip_ansi('\x1B]8;;https://example.com\x1B\\This is a link\x1B]8;;\x1B\\'))
+    "'This is a link'"
+
+    >>> repr(_strip_ansi('\x1b[31mred\x1b[0m text'))
+    "'red text'"
 
     """
-    if isinstance(s, _text_type):
-        links_removed = re.sub(_invisible_codes_link, "\\1", s)
-        return re.sub(_invisible_codes, "", links_removed)
+    if isinstance(s, str):
+        return _ansi_codes.sub(r"\4", s)
     else:  # a bytestring
-        return re.sub(_invisible_codes_bytes, "", s)
+        return _ansi_codes_bytes.sub(r"\4", s)
 
 
 def _visible_width(s):
@@ -777,14 +1064,14 @@ def _visible_width(s):
         len_fn = wcwidth.wcswidth
     else:
         len_fn = len
-    if isinstance(s, _text_type) or isinstance(s, _binary_type):
-        return len_fn(_strip_invisible(s))
+    if isinstance(s, (str, bytes)):
+        return len_fn(_strip_ansi(s))
     else:
-        return len_fn(_text_type(s))
+        return len_fn(str(s))
 
 
 def _is_multiline(s):
-    if isinstance(s, _text_type):
+    if isinstance(s, str):
         return bool(re.search(_multiline_codes, s))
     else:  # a bytestring
         return bool(re.search(_multiline_codes_bytes, s))
@@ -810,18 +1097,18 @@ def _choose_width_fn(has_invisible, enable_widechars, is_multiline):
     return width_fn
 
 
-def _align_column_choose_padfn(strings, alignment, has_invisible):
+def _align_column_choose_padfn(strings, alignment, has_invisible, preserve_whitespace):
     if alignment == "right":
-        if not PRESERVE_WHITESPACE:
+        if not preserve_whitespace:
             strings = [s.strip() for s in strings]
         padfn = _padleft
     elif alignment == "center":
-        if not PRESERVE_WHITESPACE:
+        if not preserve_whitespace:
             strings = [s.strip() for s in strings]
         padfn = _padboth
     elif alignment == "decimal":
         if has_invisible:
-            decimals = [_afterpoint(_strip_invisible(s)) for s in strings]
+            decimals = [_afterpoint(_strip_ansi(s)) for s in strings]
         else:
             decimals = [_afterpoint(s) for s in strings]
         maxdecimals = max(decimals)
@@ -830,7 +1117,7 @@ def _align_column_choose_padfn(strings, alignment, has_invisible):
     elif not alignment:
         padfn = _padnone
     else:
-        if not PRESERVE_WHITESPACE:
+        if not preserve_whitespace:
             strings = [s.strip() for s in strings]
         padfn = _padright
     return strings, padfn
@@ -873,9 +1160,10 @@ def _align_column(
     has_invisible=True,
     enable_widechars=False,
     is_multiline=False,
+    preserve_whitespace=False
 ):
     """[string] -> [padded_string]"""
-    strings, padfn = _align_column_choose_padfn(strings, alignment, has_invisible)
+    strings, padfn = _align_column_choose_padfn(strings, alignment, has_invisible, preserve_whitespace)
     width_fn = _align_column_choose_width_fn(
         has_invisible, enable_widechars, is_multiline
     )
@@ -917,20 +1205,20 @@ def _align_column(
 
 def _more_generic(type1, type2):
     types = {
-        _none_type: 0,
-        _bool_type: 1,
+        type(None): 0,
+        bool: 1,
         int: 2,
         float: 3,
-        _binary_type: 4,
-        _text_type: 5,
+        bytes: 4,
+        str: 5,
     }
     invtypes = {
-        5: _text_type,
-        4: _binary_type,
+        5: str,
+        4: bytes,
         3: float,
         2: int,
-        1: _bool_type,
-        0: _none_type,
+        1: bool,
+        0: type(None),
     }
     moregeneric = max(types.get(type1, 5), types.get(type2, 5))
     return invtypes[moregeneric]
@@ -939,30 +1227,30 @@ def _more_generic(type1, type2):
 def _column_type(strings, has_invisible=True, numparse=True):
     """The least generic type all column values are convertible to.
 
-    >>> _column_type([True, False]) is _bool_type
+    >>> _column_type([True, False]) is bool
     True
-    >>> _column_type(["1", "2"]) is _int_type
+    >>> _column_type(["1", "2"]) is int
     True
-    >>> _column_type(["1", "2.3"]) is _float_type
+    >>> _column_type(["1", "2.3"]) is float
     True
-    >>> _column_type(["1", "2.3", "four"]) is _text_type
+    >>> _column_type(["1", "2.3", "four"]) is str
     True
-    >>> _column_type(["four", '\u043f\u044f\u0442\u044c']) is _text_type
+    >>> _column_type(["four", '\u043f\u044f\u0442\u044c']) is str
     True
-    >>> _column_type([None, "brux"]) is _text_type
+    >>> _column_type([None, "brux"]) is str
     True
-    >>> _column_type([1, 2, None]) is _int_type
+    >>> _column_type([1, 2, None]) is int
     True
     >>> import datetime as dt
-    >>> _column_type([dt.datetime(1991,2,19), dt.time(17,35)]) is _text_type
+    >>> _column_type([dt.datetime(1991,2,19), dt.time(17,35)]) is str
     True
 
     """
     types = [_type(s, has_invisible, numparse) for s in strings]
-    return reduce(_more_generic, types, _bool_type)
+    return reduce(_more_generic, types, bool)
 
 
-def _format(val, valtype, floatfmt, missingval="", has_invisible=True):
+def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True):
     """Format a value according to its type.
 
     Unicode is supported:
@@ -977,25 +1265,36 @@ def _format(val, valtype, floatfmt, missingval="", has_invisible=True):
     if val is None:
         return missingval
 
-    if valtype in [int, _text_type]:
-        return "{0}".format(val)
-    elif valtype is _binary_type:
+    if valtype is str:
+        return f"{val}"
+    elif valtype is int:
+        if isinstance(val, str):
+            val_striped = val.encode('unicode_escape').decode('utf-8')
+            colored = re.search(r'(\\[xX]+[0-9a-fA-F]+\[\d+[mM]+)([0-9.]+)(\\.*)$', val_striped)
+            if colored:
+                total_groups = len(colored.groups())
+                if total_groups == 3:
+                    digits = colored.group(2)
+                    if digits.isdigit():
+                        val_new = colored.group(1) + format(int(digits), intfmt) + colored.group(3)
+                        val = val_new.encode('utf-8').decode('unicode_escape')
+            intfmt = ""
+        return format(val, intfmt)
+    elif valtype is bytes:
         try:
-            return _text_type(val, "ascii")
-        except TypeError:
-            return _text_type(val)
+            return str(val, "ascii")
+        except (TypeError, UnicodeDecodeError):
+            return str(val)
     elif valtype is float:
-        is_a_colored_number = has_invisible and isinstance(
-            val, (_text_type, _binary_type)
-        )
+        is_a_colored_number = has_invisible and isinstance(val, (str, bytes))
         if is_a_colored_number:
-            raw_val = _strip_invisible(val)
+            raw_val = _strip_ansi(val)
             formatted_val = format(float(raw_val), floatfmt)
             return val.replace(raw_val, formatted_val)
         else:
             return format(float(val), floatfmt)
     else:
-        return "{0}".format(val)
+        return f"{val}"
 
 
 def _align_header(
@@ -1016,20 +1315,48 @@ def _align_header(
     elif alignment == "center":
         return _padboth(width, header)
     elif not alignment:
-        return "{0}".format(header)
+        return f"{header}"
     else:
         return _padleft(width, header)
 
 
+def _remove_separating_lines(rows):
+    if isinstance(rows, list):
+        separating_lines = []
+        sans_rows = []
+        for index, row in enumerate(rows):
+            if _is_separating_line(row):
+                separating_lines.append(index)
+            else:
+                sans_rows.append(row)
+        return sans_rows, separating_lines
+    else:
+        return rows, None
+
+
+def _reinsert_separating_lines(rows, separating_lines):
+    if separating_lines:
+        for index in separating_lines:
+            rows.insert(index, SEPARATING_LINE)
+
+
 def _prepend_row_index(rows, index):
     """Add a left-most index column."""
     if index is None or index is False:
         return rows
-    if len(index) != len(rows):
-        print("index=", index)
-        print("rows=", rows)
-        raise ValueError("index must be as long as the number of data rows")
-    rows = [[v] + list(row) for v, row in zip(index, rows)]
+    if isinstance(index, Sized) and len(index) != len(rows):
+        raise ValueError(
+            "index must be as long as the number of data rows: "
+            + f"len(index)={len(index)} len(rows)={len(rows)}"
+        )
+    sans_rows, separating_lines = _remove_separating_lines(rows)
+    new_rows = []
+    index_iter = iter(index)
+    for row in sans_rows:
+        index_v = next(index_iter)
+        new_rows.append([index_v] + list(row))
+    rows = new_rows
+    _reinsert_separating_lines(rows, separating_lines)
     return rows
 
 
@@ -1042,7 +1369,8 @@ def _bool(val):
 
 
 def _normalize_tabular_data(tabular_data, headers, showindex="default"):
-    """Transform a supported data type to a list of lists, and a list of headers.
+    """Transform a supported data type to a list of lists, and a list of headers,
+    with headers padding.
 
     Supported tabular data types:
 
@@ -1054,6 +1382,8 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"):
 
     * list of OrderedDicts (usually used with headers="keys")
 
+    * list of dataclasses (usually used with headers="keys")
+
     * 2D NumPy arrays
 
     * NumPy record arrays (usually used with headers="keys")
@@ -1074,20 +1404,27 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"):
 
     try:
         bool(headers)
-        is_headers2bool_broken = False  # noqa
     except ValueError:  # numpy.ndarray, pandas.core.index.Index, ...
-        is_headers2bool_broken = True  # noqa
         headers = list(headers)
 
+    err_msg = (
+        "\n\nTo build a table python-tabulate requires two-dimensional data "
+        "like a list of lists or similar."
+        "\nDid you forget a pair of extra [] or ',' in ()?"
+    )
     index = None
     if hasattr(tabular_data, "keys") and hasattr(tabular_data, "values"):
         # dict-like and pandas.DataFrame?
         if hasattr(tabular_data.values, "__call__"):
             # likely a conventional dict
             keys = tabular_data.keys()
-            rows = list(
-                izip_longest(*tabular_data.values())
-            )  # columns have to be transposed
+            try:
+                rows = list(
+                    izip_longest(*tabular_data.values())
+                )  # columns have to be transposed
+            except TypeError:  # not iterable
+                raise TypeError(err_msg)
+
         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)
@@ -1107,10 +1444,13 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"):
             raise ValueError("tabular data doesn't appear to be a dict or a DataFrame")
 
         if headers == "keys":
-            headers = list(map(_text_type, keys))  # headers should be strings
+            headers = list(map(str, keys))  # headers should be strings
 
-    else:  # it's a usual an iterable of iterables, or a NumPy array
-        rows = list(tabular_data)
+    else:  # it's a usual iterable of iterables, or a NumPy array, or an iterable of dataclasses
+        try:
+            rows = list(tabular_data)
+        except TypeError:  # not iterable
+            raise TypeError(err_msg)
 
         if headers == "keys" and not rows:
             # an empty table (issue #81)
@@ -1129,7 +1469,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"):
             and hasattr(rows[0], "_fields")
         ):
             # namedtuple
-            headers = list(map(_text_type, rows[0]._fields))
+            headers = list(map(str, rows[0]._fields))
         elif len(rows) > 0 and hasattr(rows[0], "keys") and hasattr(rows[0], "values"):
             # dict-like object
             uniq_keys = set()  # implements hashed lookup
@@ -1150,11 +1490,11 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"):
             elif isinstance(headers, dict):
                 # a dict of headers for a list of dicts
                 headers = [headers.get(k, k) for k in keys]
-                headers = list(map(_text_type, headers))
+                headers = list(map(str, headers))
             elif headers == "firstrow":
                 if len(rows) > 0:
                     headers = [firstdict.get(k, k) for k in keys]
-                    headers = list(map(_text_type, headers))
+                    headers = list(map(str, headers))
                 else:
                     headers = []
             elif headers:
@@ -1173,9 +1513,20 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"):
             # print tabulate(cursor, headers='keys')
             headers = [column[0] for column in tabular_data.description]
 
+        elif (
+            dataclasses is not None
+            and len(rows) > 0
+            and dataclasses.is_dataclass(rows[0])
+        ):
+            # Python's dataclass
+            field_names = [field.name for field in dataclasses.fields(rows[0])]
+            if headers == "keys":
+                headers = field_names
+            rows = [[getattr(row, f) for f in field_names] for row in rows]
+
         elif headers == "keys" and len(rows) > 0:
             # keys are column indices
-            headers = list(map(_text_type, range(len(rows[0]))))
+            headers = list(map(str, range(len(rows[0]))))
 
     # take headers from the first row if necessary
     if headers == "firstrow" and len(rows) > 0:
@@ -1184,18 +1535,23 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"):
             index = index[1:]
         else:
             headers = rows[0]
-        headers = list(map(_text_type, headers))  # headers should be strings
+        headers = list(map(str, headers))  # headers should be strings
         rows = rows[1:]
+    elif headers == "firstrow":
+        headers = []
 
-    headers = list(map(_text_type, headers))
-    rows = list(map(list, rows))
+    headers = list(map(str, headers))
+    #    rows = list(map(list, rows))
+    rows = list(map(lambda r: r if _is_separating_line(r) else list(r), rows))
 
     # add or remove an index column
-    showindex_is_a_str = type(showindex) in [_text_type, _binary_type]
+    showindex_is_a_str = type(showindex) in [str, bytes]
     if showindex == "default" and index is not None:
         rows = _prepend_row_index(rows, index)
-    elif isinstance(showindex, Iterable) and not showindex_is_a_str:
+    elif isinstance(showindex, Sized) and not showindex_is_a_str:
         rows = _prepend_row_index(rows, list(showindex))
+    elif isinstance(showindex, Iterable) and not showindex_is_a_str:
+        rows = _prepend_row_index(rows, showindex)
     elif showindex == "always" or (_bool(showindex) and not showindex_is_a_str):
         if index is None:
             index = list(range(len(rows)))
@@ -1204,13 +1560,74 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"):
         pass
 
     # pad with empty headers for initial columns if necessary
+    headers_pad = 0
     if headers and len(rows) > 0:
-        nhs = len(headers)
-        ncols = len(rows[0])
-        if nhs < ncols:
-            headers = [""] * (ncols - nhs) + headers
+        headers_pad = max(0, len(rows[0]) - len(headers))
+        headers = [""] * headers_pad + headers
+
+    return rows, headers, headers_pad
+
+
+def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True):
+    if len(list_of_lists):
+        num_cols = len(list_of_lists[0])
+    else:
+        num_cols = 0
+    numparses = _expand_iterable(numparses, num_cols, True)
+
+    result = []
+
+    for row in list_of_lists:
+        new_row = []
+        for cell, width, numparse in zip(row, colwidths, numparses):
+            if _isnumber(cell) and numparse:
+                new_row.append(cell)
+                continue
+
+            if width is not None:
+                wrapper = _CustomTextWrap(width=width)
+                # Cast based on our internal type handling
+                # Any future custom formatting of types (such as datetimes)
+                # may need to be more explicit than just `str` of the object
+                casted_cell = (
+                    str(cell) if _isnumber(cell) else _type(cell, numparse)(cell)
+                )
+                wrapped = [
+                    "\n".join(wrapper.wrap(line))
+                    for line in casted_cell.splitlines()
+                    if line.strip() != ""
+                ]
+                new_row.append("\n".join(wrapped))
+            else:
+                new_row.append(cell)
+        result.append(new_row)
+
+    return result
+
+
+def _to_str(s, encoding="utf8", errors="ignore"):
+    """
+    A type safe wrapper for converting a bytestring to str. This is essentially just
+    a wrapper around .decode() intended for use with things like map(), but with some
+    specific behavior:
 
-    return rows, headers
+    1. if the given parameter is not a bytestring, it is returned unmodified
+    2. decode() is called for the given parameter and assumes utf8 encoding, but the
+       default error behavior is changed from 'strict' to 'ignore'
+
+    >>> repr(_to_str(b'foo'))
+    "'foo'"
+
+    >>> repr(_to_str('foo'))
+    "'foo'"
+
+    >>> repr(_to_str(42))
+    "'42'"
+
+    """
+    if isinstance(s, bytes):
+        return s.decode(encoding=encoding, errors=errors)
+    return str(s)
 
 
 def tabulate(
@@ -1218,12 +1635,20 @@ def tabulate(
     headers=(),
     tablefmt="simple",
     floatfmt=_DEFAULT_FLOATFMT,
+    intfmt=_DEFAULT_INTFMT,
     numalign=_DEFAULT_ALIGN,
     stralign=_DEFAULT_ALIGN,
     missingval=_DEFAULT_MISSINGVAL,
     showindex="default",
     disable_numparse=False,
+    colglobalalign=None,
     colalign=None,
+    preserve_whitespace=False,
+    maxcolwidths=None,
+    headersglobalalign=None,
+    headersalign=None,
+    rowalign=None,
+    maxheadercolwidths=None,
 ):
     """Format a fixed width table for pretty printing.
 
@@ -1237,8 +1662,8 @@ def tabulate(
     The first required argument (`tabular_data`) can be a
     list-of-lists (or another iterable of iterables), a list of named
     tuples, a dictionary of iterables, an iterable of dictionaries,
-    a two-dimensional NumPy array, NumPy record array, or a Pandas'
-    dataframe.
+    an iterable of dataclasses, a two-dimensional NumPy array,
+    NumPy record array, or a Pandas' dataframe.
 
 
     Table headers
@@ -1276,8 +1701,8 @@ def tabulate(
     -  -  --
 
 
-    Column alignment
-    ----------------
+    Column and Headers alignment
+    ----------------------------
 
     `tabulate` tries to detect column types automatically, and aligns
     the values properly. By default it aligns decimal points of the
@@ -1286,10 +1711,31 @@ def tabulate(
     (`numalign`, `stralign`) are: "right", "center", "left", "decimal"
     (only for `numalign`), and None (to disable alignment).
 
+    `colglobalalign` allows for global alignment of columns, before any
+        specific override from `colalign`. Possible values are: None
+        (defaults according to coltype), "right", "center", "decimal",
+        "left".
+    `colalign` allows for column-wise override starting from left-most
+        column. Possible values are: "global" (no override), "right",
+        "center", "decimal", "left".
+    `headersglobalalign` allows for global headers alignment, before any
+        specific override from `headersalign`. Possible values are: None
+        (follow columns alignment), "right", "center", "left".
+    `headersalign` allows for header-wise override starting from left-most
+        given header. Possible values are: "global" (no override), "same"
+        (follow column alignment), "right", "center", "left".
+
+    Note on intended behaviour: If there is no `tabular_data`, any column
+        alignment argument is ignored. Hence, in this case, header
+        alignment cannot be inferred from column alignment.
 
     Table formats
     -------------
 
+    `intfmt` is a format specification used for columns which
+    contain numeric data without a decimal point. This can also be
+    a list or tuple of format strings, one per column.
+
     `floatfmt` is a format specification used for columns which
     contain numeric data with a decimal point. This can also be
     a list or tuple of format strings, one per column.
@@ -1360,7 +1806,73 @@ def tabulate(
     | eggs | 451      |
     +------+----------+
 
-    "fancy_grid" draws a grid using box-drawing characters:
+    "simple_grid" draws a grid using single-line box-drawing
+    characters:
+
+    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]],
+    ...                ["strings", "numbers"], "simple_grid"))
+    ┌───────────┬───────────┐
+    │ strings   │   numbers │
+    ├───────────┼───────────┤
+    │ spam      │   41.9999 │
+    ├───────────┼───────────┤
+    │ eggs      │  451      │
+    └───────────┴───────────┘
+
+    "rounded_grid" draws a grid using single-line box-drawing
+    characters with rounded corners:
+
+    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]],
+    ...                ["strings", "numbers"], "rounded_grid"))
+    ╭───────────┬───────────╮
+    │ strings   │   numbers │
+    ├───────────┼───────────┤
+    │ spam      │   41.9999 │
+    ├───────────┼───────────┤
+    │ eggs      │  451      │
+    ╰───────────┴───────────╯
+
+    "heavy_grid" draws a grid using bold (thick) single-line box-drawing
+    characters:
+
+    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]],
+    ...                ["strings", "numbers"], "heavy_grid"))
+    ┏━━━━━━━━━━━┳━━━━━━━━━━━┓
+    ┃ strings   ┃   numbers ┃
+    ┣━━━━━━━━━━━╋━━━━━━━━━━━┫
+    ┃ spam      ┃   41.9999 ┃
+    ┣━━━━━━━━━━━╋━━━━━━━━━━━┫
+    ┃ eggs      ┃  451      ┃
+    ┗━━━━━━━━━━━┻━━━━━━━━━━━┛
+
+    "mixed_grid" draws a grid using a mix of light (thin) and heavy (thick) lines
+    box-drawing characters:
+
+    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]],
+    ...                ["strings", "numbers"], "mixed_grid"))
+    ┍━━━━━━━━━━━┯━━━━━━━━━━━┑
+    │ strings   │   numbers │
+    ┝━━━━━━━━━━━┿━━━━━━━━━━━┥
+    │ spam      │   41.9999 │
+    ├───────────┼───────────┤
+    │ eggs      │  451      │
+    ┕━━━━━━━━━━━┷━━━━━━━━━━━┙
+
+    "double_grid" draws a grid using double-line box-drawing
+    characters:
+
+    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]],
+    ...                ["strings", "numbers"], "double_grid"))
+    ╔═══════════╦═══════════╗
+    ║ strings   ║   numbers ║
+    ╠═══════════╬═══════════╣
+    ║ spam      ║   41.9999 ║
+    ╠═══════════╬═══════════╣
+    ║ eggs      ║  451      ║
+    ╚═══════════╩═══════════╝
+
+    "fancy_grid" draws a grid using a mix of single and
+    double-line box-drawing characters:
 
     >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]],
     ...                ["strings", "numbers"], "fancy_grid"))
@@ -1372,6 +1884,113 @@ def tabulate(
     │ eggs      │  451      │
     ╘═══════════╧═══════════╛
 
+    "colon_grid" is similar to "grid" but uses colons only to define
+    columnwise content alignment, with no whitespace padding:
+
+    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]],
+    ...                ["strings", "numbers"], "colon_grid"))
+    +-----------+-----------+
+    | strings   | numbers   |
+    +:==========+:==========+
+    | spam      | 41.9999   |
+    +-----------+-----------+
+    | eggs      | 451       |
+    +-----------+-----------+
+
+    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]],
+    ...                ["strings", "numbers"], "colon_grid",
+    ...                colalign=["right", "left"]))
+    +-----------+-----------+
+    | strings   | numbers   |
+    +==========:+:==========+
+    | spam      | 41.9999   |
+    +-----------+-----------+
+    | eggs      | 451       |
+    +-----------+-----------+
+
+    "outline" is the same as the "grid" format but doesn't draw lines between rows:
+
+    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]],
+    ...                ["strings", "numbers"], "outline"))
+    +-----------+-----------+
+    | strings   |   numbers |
+    +===========+===========+
+    | spam      |   41.9999 |
+    | eggs      |  451      |
+    +-----------+-----------+
+
+    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]], tablefmt="outline"))
+    +------+----------+
+    | spam |  41.9999 |
+    | eggs | 451      |
+    +------+----------+
+
+    "simple_outline" is the same as the "simple_grid" format but doesn't draw lines between rows:
+
+    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]],
+    ...                ["strings", "numbers"], "simple_outline"))
+    ┌───────────┬───────────┐
+    │ strings   │   numbers │
+    ├───────────┼───────────┤
+    │ spam      │   41.9999 │
+    │ eggs      │  451      │
+    └───────────┴───────────┘
+
+    "rounded_outline" is the same as the "rounded_grid" format but doesn't draw lines between rows:
+
+    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]],
+    ...                ["strings", "numbers"], "rounded_outline"))
+    ╭───────────┬───────────╮
+    │ strings   │   numbers │
+    ├───────────┼───────────┤
+    │ spam      │   41.9999 │
+    │ eggs      │  451      │
+    ╰───────────┴───────────╯
+
+    "heavy_outline" is the same as the "heavy_grid" format but doesn't draw lines between rows:
+
+    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]],
+    ...                ["strings", "numbers"], "heavy_outline"))
+    ┏━━━━━━━━━━━┳━━━━━━━━━━━┓
+    ┃ strings   ┃   numbers ┃
+    ┣━━━━━━━━━━━╋━━━━━━━━━━━┫
+    ┃ spam      ┃   41.9999 ┃
+    ┃ eggs      ┃  451      ┃
+    ┗━━━━━━━━━━━┻━━━━━━━━━━━┛
+
+    "mixed_outline" is the same as the "mixed_grid" format but doesn't draw lines between rows:
+
+    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]],
+    ...                ["strings", "numbers"], "mixed_outline"))
+    ┍━━━━━━━━━━━┯━━━━━━━━━━━┑
+    │ strings   │   numbers │
+    ┝━━━━━━━━━━━┿━━━━━━━━━━━┥
+    │ spam      │   41.9999 │
+    │ eggs      │  451      │
+    ┕━━━━━━━━━━━┷━━━━━━━━━━━┙
+
+    "double_outline" is the same as the "double_grid" format but doesn't draw lines between rows:
+
+    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]],
+    ...                ["strings", "numbers"], "double_outline"))
+    ╔═══════════╦═══════════╗
+    ║ strings   ║   numbers ║
+    ╠═══════════╬═══════════╣
+    ║ spam      ║   41.9999 ║
+    ║ eggs      ║  451      ║
+    ╚═══════════╩═══════════╝
+
+    "fancy_outline" is the same as the "fancy_grid" format but doesn't draw lines between rows:
+
+    >>> print(tabulate([["spam", 41.9999], ["eggs", "451.0"]],
+    ...                ["strings", "numbers"], "fancy_outline"))
+    ╒═══════════╤═══════════╕
+    │ strings   │   numbers │
+    ╞═══════════╪═══════════╡
+    │ spam      │   41.9999 │
+    │ eggs      │  451      │
+    ╘═══════════╧═══════════╛
+
     "pipe" is like tables in PHP Markdown Extra extension or Pandoc
     pipe_tables:
 
@@ -1439,11 +2058,11 @@ def tabulate(
     {| class="wikitable" style="text-align: left;"
     |+ <!-- caption -->
     |-
-    ! strings   !! align="right"|   numbers
+    ! strings   !! style="text-align: right;"|   numbers
     |-
-    | spam      || align="right"|   41.9999
+    | spam      || style="text-align: right;"|   41.9999
     |-
-    | eggs      || align="right"|  451
+    | eggs      || style="text-align: right;"|  451
     |}
 
     "html" produces HTML markup as an html.escape'd str
@@ -1521,13 +2140,70 @@ def tabulate(
     indices is used to disable number parsing only on those columns
     e.g. `disable_numparse=[0, 2]` would disable number parsing only on the
     first and third columns.
+
+    Column Widths and Auto Line Wrapping
+    ------------------------------------
+    Tabulate will, by default, set the width of each column to the length of the
+    longest element in that column. However, in situations where fields are expected
+    to reasonably be too long to look good as a single line, tabulate can help automate
+    word wrapping long fields for you. Use the parameter `maxcolwidth` to provide a
+    list of maximal column widths
+
+    >>> print(tabulate( \
+          [('1', 'John Smith', \
+            'This is a rather long description that might look better if it is wrapped a bit')], \
+          headers=("Issue Id", "Author", "Description"), \
+          maxcolwidths=[None, None, 30], \
+          tablefmt="grid"  \
+        ))
+    +------------+------------+-------------------------------+
+    |   Issue Id | Author     | Description                   |
+    +============+============+===============================+
+    |          1 | John Smith | This is a rather long         |
+    |            |            | description that might look   |
+    |            |            | better if it is wrapped a bit |
+    +------------+------------+-------------------------------+
+
+    Header column width can be specified in a similar way using `maxheadercolwidth`
+
     """
 
     if tabular_data is None:
         tabular_data = []
-    list_of_lists, headers = _normalize_tabular_data(
+
+    list_of_lists, headers, headers_pad = _normalize_tabular_data(
         tabular_data, headers, showindex=showindex
     )
+    list_of_lists, separating_lines = _remove_separating_lines(list_of_lists)
+
+    if maxcolwidths is not None:
+        if len(list_of_lists):
+            num_cols = len(list_of_lists[0])
+        else:
+            num_cols = 0
+        if isinstance(maxcolwidths, int):  # Expand scalar for all columns
+            maxcolwidths = _expand_iterable(maxcolwidths, num_cols, maxcolwidths)
+        else:  # Ignore col width for any 'trailing' columns
+            maxcolwidths = _expand_iterable(maxcolwidths, num_cols, None)
+
+        numparses = _expand_numparse(disable_numparse, num_cols)
+        list_of_lists = _wrap_text_to_colwidths(
+            list_of_lists, maxcolwidths, numparses=numparses
+        )
+
+    if maxheadercolwidths is not None:
+        num_cols = len(list_of_lists[0])
+        if isinstance(maxheadercolwidths, int):  # Expand scalar for all columns
+            maxheadercolwidths = _expand_iterable(
+                maxheadercolwidths, num_cols, maxheadercolwidths
+            )
+        else:  # Ignore col width for any 'trailing' columns
+            maxheadercolwidths = _expand_iterable(maxheadercolwidths, num_cols, None)
+
+        numparses = _expand_numparse(disable_numparse, num_cols)
+        headers = _wrap_text_to_colwidths(
+            [headers], maxheadercolwidths, numparses=numparses
+        )[0]
 
     # empty values in the first column of RST tables should be escaped (issue #82)
     # "" should be escaped as "\\ " or ".."
@@ -1548,16 +2224,30 @@ def tabulate(
         numalign = "decimal" if numalign == _DEFAULT_ALIGN else numalign
         stralign = "left" if stralign == _DEFAULT_ALIGN else stralign
 
+    # 'colon_grid' uses colons in the line beneath the header to represent a column's
+    # alignment instead of literally aligning the text differently. Hence,
+    # left alignment of the data in the text output is enforced.
+    if tablefmt == "colon_grid":
+        colglobalalign = "left"
+        headersglobalalign = "left"
+
     # optimization: look for ANSI control codes once,
     # enable smart width functions only if a control code is found
+    #
+    # convert the headers and rows into a single, tab-delimited string ensuring
+    # that any bytestrings are decoded safely (i.e. errors ignored)
     plain_text = "\t".join(
-        ["\t".join(map(_text_type, headers))]
-        + ["\t".join(map(_text_type, row)) for row in list_of_lists]
+        chain(
+            # headers
+            map(_to_str, headers),
+            # rows: chain the rows together into a single iterable after mapping
+            # the bytestring conversino to each cell value
+            chain.from_iterable(map(_to_str, row) for row in list_of_lists),
+        )
     )
 
-    has_invisible = re.search(_invisible_codes, plain_text)
-    if not has_invisible:
-        has_invisible = re.search(_invisible_codes_link, plain_text)
+    has_invisible = _ansi_codes.search(plain_text) is not None
+
     enable_widechars = wcwidth is not None and WIDE_CHARS_MODE
     if (
         not isinstance(tablefmt, TableFormat)
@@ -1574,7 +2264,7 @@ def tabulate(
     cols = list(izip_longest(*list_of_lists))
     numparses = _expand_numparse(disable_numparse, len(cols))
     coltypes = [_column_type(col, numparse=np) for col, np in zip(cols, numparses)]
-    if isinstance(floatfmt, basestring):  # old version
+    if isinstance(floatfmt, str):  # old version
         float_formats = len(cols) * [
             floatfmt
         ]  # just duplicate the string to use in each column
@@ -1582,42 +2272,94 @@ def tabulate(
         float_formats = list(floatfmt)
         if len(float_formats) < len(cols):
             float_formats.extend((len(cols) - len(float_formats)) * [_DEFAULT_FLOATFMT])
-    if isinstance(missingval, basestring):
+    if isinstance(intfmt, str):  # old version
+        int_formats = len(cols) * [
+            intfmt
+        ]  # just duplicate the string to use in each column
+    else:  # if intfmt is list, tuple etc we have one per column
+        int_formats = list(intfmt)
+        if len(int_formats) < len(cols):
+            int_formats.extend((len(cols) - len(int_formats)) * [_DEFAULT_INTFMT])
+    if isinstance(missingval, str):
         missing_vals = len(cols) * [missingval]
     else:
         missing_vals = list(missingval)
         if len(missing_vals) < len(cols):
             missing_vals.extend((len(cols) - len(missing_vals)) * [_DEFAULT_MISSINGVAL])
     cols = [
-        [_format(v, ct, fl_fmt, miss_v, has_invisible) for v in c]
-        for c, ct, fl_fmt, miss_v in zip(cols, coltypes, float_formats, missing_vals)
+        [_format(v, ct, fl_fmt, int_fmt, miss_v, has_invisible) for v in c]
+        for c, ct, fl_fmt, int_fmt, miss_v in zip(
+            cols, coltypes, float_formats, int_formats, missing_vals
+        )
     ]
 
     # align columns
-    aligns = [numalign if ct in [int, float] else stralign for ct in coltypes]
+    # first set global alignment
+    if colglobalalign is not None:  # if global alignment provided
+        aligns = [colglobalalign] * len(cols)
+    else:  # default
+        aligns = [numalign if ct in [int, float] else stralign for ct in coltypes]
+    # then specific alignments
     if colalign is not None:
         assert isinstance(colalign, Iterable)
+        if isinstance(colalign, str):
+            warnings.warn(
+                f"As a string, `colalign` is interpreted as {[c for c in colalign]}. "
+                f'Did you mean `colglobalalign = "{colalign}"` or `colalign = ("{colalign}",)`?',
+                stacklevel=2,
+            )
         for idx, align in enumerate(colalign):
-            aligns[idx] = align
+            if not idx < len(aligns):
+                break
+            elif align != "global":
+                aligns[idx] = align
     minwidths = (
         [width_fn(h) + min_padding for h in headers] if headers else [0] * len(cols)
     )
+    aligns_copy = aligns.copy()
+    # Reset alignments in copy of alignments list to "left" for 'colon_grid' format,
+    # which enforces left alignment in the text output of the data.
+    if tablefmt == "colon_grid":
+        aligns_copy = ["left"] * len(cols)
     cols = [
-        _align_column(c, a, minw, has_invisible, enable_widechars, is_multiline)
-        for c, a, minw in zip(cols, aligns, minwidths)
+        _align_column(c, a, minw, has_invisible, enable_widechars, is_multiline, preserve_whitespace)
+        for c, a, minw in zip(cols, aligns_copy, minwidths)
     ]
 
+    aligns_headers = None
     if headers:
         # align headers and add headers
         t_cols = cols or [[""]] * len(headers)
-        t_aligns = aligns or [stralign] * len(headers)
+        # first set global alignment
+        if headersglobalalign is not None:  # if global alignment provided
+            aligns_headers = [headersglobalalign] * len(t_cols)
+        else:  # default
+            aligns_headers = aligns or [stralign] * len(headers)
+        # then specific header alignments
+        if headersalign is not None:
+            assert isinstance(headersalign, Iterable)
+            if isinstance(headersalign, str):
+                warnings.warn(
+                    f"As a string, `headersalign` is interpreted as {[c for c in headersalign]}. "
+                    f'Did you mean `headersglobalalign = "{headersalign}"` '
+                    f'or `headersalign = ("{headersalign}",)`?',
+                    stacklevel=2,
+                )
+            for idx, align in enumerate(headersalign):
+                hidx = headers_pad + idx
+                if not hidx < len(aligns_headers):
+                    break
+                elif align == "same" and hidx < len(aligns):  # same as column align
+                    aligns_headers[hidx] = aligns[hidx]
+                elif align != "global":
+                    aligns_headers[hidx] = align
         minwidths = [
             max(minw, max(width_fn(cl) for cl in c))
             for minw, c in zip(minwidths, t_cols)
         ]
         headers = [
             _align_header(h, a, minw, width_fn(h), is_multiline, width_fn)
-            for h, a, minw in zip(headers, t_aligns, minwidths)
+            for h, a, minw in zip(headers, aligns_headers, minwidths)
         ]
         rows = list(zip(*cols))
     else:
@@ -1627,7 +2369,20 @@ def tabulate(
     if not isinstance(tablefmt, TableFormat):
         tablefmt = _table_formats.get(tablefmt, _table_formats["simple"])
 
-    return _format_table(tablefmt, headers, rows, minwidths, aligns, is_multiline)
+    ra_default = rowalign if isinstance(rowalign, str) else None
+    rowaligns = _expand_iterable(rowalign, len(rows), ra_default)
+    _reinsert_separating_lines(rows, separating_lines)
+
+    return _format_table(
+        tablefmt,
+        headers,
+        aligns_headers,
+        rows,
+        minwidths,
+        aligns,
+        is_multiline,
+        rowaligns=rowaligns,
+    )
 
 
 def _expand_numparse(disable_numparse, column_count):
@@ -1647,8 +2402,24 @@ def _expand_numparse(disable_numparse, column_count):
         return [not disable_numparse] * column_count
 
 
+def _expand_iterable(original, num_desired, default):
+    """
+    Expands the `original` argument to return a return a list of
+    length `num_desired`. If `original` is shorter than `num_desired`, it will
+    be padded with the value in `default`.
+    If `original` is not a list to begin with (i.e. scalar value) a list of
+    length `num_desired` completely populated with `default will be returned
+    """
+    if isinstance(original, Iterable) and not isinstance(original, str):
+        return original + [default] * (num_desired - len(original))
+    else:
+        return [default] * num_desired
+
+
 def _pad_row(cells, padding):
     if cells:
+        if cells == SEPARATING_LINE:
+            return SEPARATING_LINE
         pad = " " * padding
         padded_cells = [pad + cell + pad for cell in cells]
         return padded_cells
@@ -1672,20 +2443,39 @@ def _build_row(padded_cells, colwidths, colaligns, rowfmt):
         return _build_simple_row(padded_cells, rowfmt)
 
 
-def _append_basic_row(lines, padded_cells, colwidths, colaligns, rowfmt):
+def _append_basic_row(lines, padded_cells, colwidths, colaligns, rowfmt, rowalign=None):
+    # NOTE: rowalign is ignored and exists for api compatibility with _append_multiline_row
     lines.append(_build_row(padded_cells, colwidths, colaligns, rowfmt))
     return lines
 
 
+def _align_cell_veritically(text_lines, num_lines, column_width, row_alignment):
+    delta_lines = num_lines - len(text_lines)
+    blank = [" " * column_width]
+    if row_alignment == "bottom":
+        return blank * delta_lines + text_lines
+    elif row_alignment == "center":
+        top_delta = delta_lines // 2
+        bottom_delta = delta_lines - top_delta
+        return top_delta * blank + text_lines + bottom_delta * blank
+    else:
+        return text_lines + blank * delta_lines
+
+
 def _append_multiline_row(
-    lines, padded_multiline_cells, padded_widths, colaligns, rowfmt, pad
+    lines, padded_multiline_cells, padded_widths, colaligns, rowfmt, pad, rowalign=None
 ):
     colwidths = [w - 2 * pad for w in padded_widths]
     cells_lines = [c.splitlines() for c in padded_multiline_cells]
     nlines = max(map(len, cells_lines))  # number of lines in the row
     # vertically pad cells where some lines are missing
+    # cells_lines = [
+    #     (cl + [" " * w] * (nlines - len(cl))) for cl, w in zip(cells_lines, colwidths)
+    # ]
+
     cells_lines = [
-        (cl + [" " * w] * (nlines - len(cl))) for cl, w in zip(cells_lines, colwidths)
+        _align_cell_veritically(cl, nlines, w, rowalign)
+        for cl, w in zip(cells_lines, colwidths)
     ]
     lines_cells = [[cl[i] for cl in cells_lines] for i in range(nlines)]
     for ln in lines_cells:
@@ -1724,7 +2514,9 @@ def str(self):
         return self
 
 
-def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline):
+def _format_table(
+    fmt, headers, headersaligns, rows, colwidths, colaligns, is_multiline, rowaligns
+):
     """Produce a plain-text representation of the table."""
     lines = []
     hidden = fmt.with_header_hide if (headers and fmt.with_header_hide) else []
@@ -1746,20 +2538,42 @@ def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline):
         _append_line(lines, padded_widths, colaligns, fmt.lineabove)
 
     if padded_headers:
-        append_row(lines, padded_headers, padded_widths, colaligns, headerrow)
+        append_row(lines, padded_headers, padded_widths, headersaligns, headerrow)
         if fmt.linebelowheader and "linebelowheader" not in hidden:
             _append_line(lines, padded_widths, colaligns, fmt.linebelowheader)
 
     if padded_rows and fmt.linebetweenrows and "linebetweenrows" not in hidden:
         # initial rows with a line below
-        for row in padded_rows[:-1]:
-            append_row(lines, row, padded_widths, colaligns, fmt.datarow)
+        for row, ralign in zip(padded_rows[:-1], rowaligns):
+            if row != SEPARATING_LINE:
+                append_row(
+                    lines, row, padded_widths, colaligns, fmt.datarow, rowalign=ralign
+                )
             _append_line(lines, padded_widths, colaligns, fmt.linebetweenrows)
         # the last row without a line below
-        append_row(lines, padded_rows[-1], padded_widths, colaligns, fmt.datarow)
+        append_row(
+            lines,
+            padded_rows[-1],
+            padded_widths,
+            colaligns,
+            fmt.datarow,
+            rowalign=rowaligns[-1],
+        )
     else:
+        separating_line = (
+            fmt.linebetweenrows
+            or fmt.linebelowheader
+            or fmt.linebelow
+            or fmt.lineabove
+            or Line("", "", "", "")
+        )
         for row in padded_rows:
-            append_row(lines, row, padded_widths, colaligns, fmt.datarow)
+            # test to see if either the 1st column or the 2nd column (account for showindex) has
+            # the SEPARATING_LINE flag
+            if _is_separating_line(row):
+                _append_line(lines, padded_widths, colaligns, separating_line)
+            else:
+                append_row(lines, row, padded_widths, colaligns, fmt.datarow)
 
     if fmt.linebelow and "linebelow" not in hidden:
         _append_line(lines, padded_widths, colaligns, fmt.linebelow)
@@ -1774,6 +2588,206 @@ def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline):
         return ""
 
 
+class _CustomTextWrap(textwrap.TextWrapper):
+    """A custom implementation of CPython's textwrap.TextWrapper. This supports
+    both wide characters (Korea, Japanese, Chinese)  - including mixed string.
+    For the most part, the `_handle_long_word` and `_wrap_chunks` functions were
+    copy pasted out of the CPython baseline, and updated with our custom length
+    and line appending logic.
+    """
+
+    def __init__(self, *args, **kwargs):
+        self._active_codes = []
+        self.max_lines = None  # For python2 compatibility
+        textwrap.TextWrapper.__init__(self, *args, **kwargs)
+
+    @staticmethod
+    def _len(item):
+        """Custom len that gets console column width for wide
+        and non-wide characters as well as ignores color codes"""
+        stripped = _strip_ansi(item)
+        if wcwidth:
+            return wcwidth.wcswidth(stripped)
+        else:
+            return len(stripped)
+
+    def _update_lines(self, lines, new_line):
+        """Adds a new line to the list of lines the text is being wrapped into
+        This function will also track any ANSI color codes in this string as well
+        as add any colors from previous lines order to preserve the same formatting
+        as a single unwrapped string.
+        """
+        code_matches = [x for x in _ansi_codes.finditer(new_line)]
+        color_codes = [
+            code.string[code.span()[0] : code.span()[1]] for code in code_matches
+        ]
+
+        # Add color codes from earlier in the unwrapped line, and then track any new ones we add.
+        new_line = "".join(self._active_codes) + new_line
+
+        for code in color_codes:
+            if code != _ansi_color_reset_code:
+                self._active_codes.append(code)
+            else:  # A single reset code resets everything
+                self._active_codes = []
+
+        # Always ensure each line is color terminted if any colors are
+        # still active, otherwise colors will bleed into other cells on the console
+        if len(self._active_codes) > 0:
+            new_line = new_line + _ansi_color_reset_code
+
+        lines.append(new_line)
+
+    def _handle_long_word(self, reversed_chunks, cur_line, cur_len, width):
+        """_handle_long_word(chunks : [string],
+                             cur_line : [string],
+                             cur_len : int, width : int)
+        Handle a chunk of text (most likely a word, not whitespace) that
+        is too long to fit in any line.
+        """
+        # Figure out when indent is larger than the specified width, and make
+        # sure at least one character is stripped off on every pass
+        if width < 1:
+            space_left = 1
+        else:
+            space_left = width - cur_len
+
+        # If we're allowed to break long words, then do so: put as much
+        # of the next chunk onto the current line as will fit.
+        if self.break_long_words:
+            # Tabulate Custom: Build the string up piece-by-piece in order to
+            # take each charcter's width into account
+            chunk = reversed_chunks[-1]
+            i = 1
+            while self._len(chunk[:i]) <= space_left:
+                i = i + 1
+            cur_line.append(chunk[: i - 1])
+            reversed_chunks[-1] = chunk[i - 1 :]
+
+        # Otherwise, we have to preserve the long word intact.  Only add
+        # it to the current line if there's nothing already there --
+        # that minimizes how much we violate the width constraint.
+        elif not cur_line:
+            cur_line.append(reversed_chunks.pop())
+
+        # If we're not allowed to break long words, and there's already
+        # text on the current line, do nothing.  Next time through the
+        # main loop of _wrap_chunks(), we'll wind up here again, but
+        # cur_len will be zero, so the next line will be entirely
+        # devoted to the long word that we can't handle right now.
+
+    def _wrap_chunks(self, chunks):
+        """_wrap_chunks(chunks : [string]) -> [string]
+        Wrap a sequence of text chunks and return a list of lines of
+        length 'self.width' or less.  (If 'break_long_words' is false,
+        some lines may be longer than this.)  Chunks correspond roughly
+        to words and the whitespace between them: each chunk is
+        indivisible (modulo 'break_long_words'), but a line break can
+        come between any two chunks.  Chunks should not have internal
+        whitespace; ie. a chunk is either all whitespace or a "word".
+        Whitespace chunks will be removed from the beginning and end of
+        lines, but apart from that whitespace is preserved.
+        """
+        lines = []
+        if self.width <= 0:
+            raise ValueError("invalid width %r (must be > 0)" % self.width)
+        if self.max_lines is not None:
+            if self.max_lines > 1:
+                indent = self.subsequent_indent
+            else:
+                indent = self.initial_indent
+            if self._len(indent) + self._len(self.placeholder.lstrip()) > self.width:
+                raise ValueError("placeholder too large for max width")
+
+        # Arrange in reverse order so items can be efficiently popped
+        # from a stack of chucks.
+        chunks.reverse()
+
+        while chunks:
+
+            # Start the list of chunks that will make up the current line.
+            # cur_len is just the length of all the chunks in cur_line.
+            cur_line = []
+            cur_len = 0
+
+            # Figure out which static string will prefix this line.
+            if lines:
+                indent = self.subsequent_indent
+            else:
+                indent = self.initial_indent
+
+            # Maximum width for this line.
+            width = self.width - self._len(indent)
+
+            # First chunk on line is whitespace -- drop it, unless this
+            # is the very beginning of the text (ie. no lines started yet).
+            if self.drop_whitespace and chunks[-1].strip() == "" and lines:
+                del chunks[-1]
+
+            while chunks:
+                chunk_len = self._len(chunks[-1])
+
+                # Can at least squeeze this chunk onto the current line.
+                if cur_len + chunk_len <= width:
+                    cur_line.append(chunks.pop())
+                    cur_len += chunk_len
+
+                # Nope, this line is full.
+                else:
+                    break
+
+            # The current line is full, and the next chunk is too big to
+            # fit on *any* line (not just this one).
+            if chunks and self._len(chunks[-1]) > width:
+                self._handle_long_word(chunks, cur_line, cur_len, width)
+                cur_len = sum(map(self._len, cur_line))
+
+            # If the last chunk on this line is all whitespace, drop it.
+            if self.drop_whitespace and cur_line and cur_line[-1].strip() == "":
+                cur_len -= self._len(cur_line[-1])
+                del cur_line[-1]
+
+            if cur_line:
+                if (
+                    self.max_lines is None
+                    or len(lines) + 1 < self.max_lines
+                    or (
+                        not chunks
+                        or self.drop_whitespace
+                        and len(chunks) == 1
+                        and not chunks[0].strip()
+                    )
+                    and cur_len <= width
+                ):
+                    # Convert current line back to a string and store it in
+                    # list of all lines (return value).
+                    self._update_lines(lines, indent + "".join(cur_line))
+                else:
+                    while cur_line:
+                        if (
+                            cur_line[-1].strip()
+                            and cur_len + self._len(self.placeholder) <= width
+                        ):
+                            cur_line.append(self.placeholder)
+                            self._update_lines(lines, indent + "".join(cur_line))
+                            break
+                        cur_len -= self._len(cur_line[-1])
+                        del cur_line[-1]
+                    else:
+                        if lines:
+                            prev_line = lines[-1].rstrip()
+                            if (
+                                self._len(prev_line) + self._len(self.placeholder)
+                                <= self.width
+                            ):
+                                lines[-1] = prev_line + self.placeholder
+                                break
+                        self._update_lines(lines, indent + self.placeholder.lstrip())
+                    break
+
+        return lines
+
+
 def _main():
     """\
     Usage: tabulate [options] [FILE ...]
@@ -1791,6 +2805,7 @@ def _main():
     -o FILE, --output FILE    print table to FILE (default: stdout)
     -s REGEXP, --sep REGEXP   use a custom column separator (default: whitespace)
     -F FPFMT, --float FPFMT   floating point number format (default: g)
+    -I INTFMT, --int INTFMT   integer point number format (default: "")
     -f FMT, --format FMT      set output table format; supported formats:
                               plain, simple, grid, fancy_grid, pipe, orgtbl,
                               rst, mediawiki, html, latex, latex_raw,
@@ -1798,15 +2813,13 @@ def _main():
                               (default: simple)
     """
     import getopt
-    import sys
-    import textwrap
 
     usage = textwrap.dedent(_main.__doc__)
     try:
         opts, args = getopt.getopt(
             sys.argv[1:],
-            "h1o:s:F:A:f:",
-            ["help", "header", "output", "sep=", "float=", "align=", "format="],
+            "h1o:s:F:I:f:",
+            ["help", "header", "output=", "sep=", "float=", "int=", "colalign=", "format="],
         )
     except getopt.GetoptError as e:
         print(e)
@@ -1814,6 +2827,7 @@ def _main():
         sys.exit(2)
     headers = []
     floatfmt = _DEFAULT_FLOATFMT
+    intfmt = _DEFAULT_INTFMT
     colalign = None
     tablefmt = "simple"
     sep = r"\s+"
@@ -1825,6 +2839,8 @@ def _main():
             outfile = value
         elif opt in ["-F", "--float"]:
             floatfmt = value
+        elif opt in ["-I", "--int"]:
+            intfmt = value
         elif opt in ["-C", "--colalign"]:
             colalign = value.split()
         elif opt in ["-f", "--format"]:
@@ -1839,7 +2855,7 @@ def _main():
             print(usage)
             sys.exit(0)
     files = [sys.stdin] if not args else args
-    with (sys.stdout if outfile == "-" else open(outfile, "w")) as out:
+    with sys.stdout if outfile == "-" else open(outfile, "w") as out:
         for f in files:
             if f == "-":
                 f = sys.stdin
@@ -1850,6 +2866,7 @@ def _main():
                     tablefmt=tablefmt,
                     sep=sep,
                     floatfmt=floatfmt,
+                    intfmt=intfmt,
                     file=out,
                     colalign=colalign,
                 )
@@ -1861,16 +2878,24 @@ def _main():
                         tablefmt=tablefmt,
                         sep=sep,
                         floatfmt=floatfmt,
+                        intfmt=intfmt,
                         file=out,
                         colalign=colalign,
                     )
 
 
-def _pprint_file(fobject, headers, tablefmt, sep, floatfmt, file, colalign):
+def _pprint_file(fobject, headers, tablefmt, sep, floatfmt, intfmt, file, colalign):
     rows = fobject.readlines()
     table = [re.split(sep, r.rstrip()) for r in rows if r.strip()]
     print(
-        tabulate(table, headers, tablefmt, floatfmt=floatfmt, colalign=colalign),
+        tabulate(
+            table,
+            headers,
+            tablefmt,
+            floatfmt=floatfmt,
+            intfmt=intfmt,
+            colalign=colalign,
+        ),
         file=file,
     )
 
diff --git a/tox.ini b/tox.ini
index a20b325..a7fe513 100644
--- a/tox.ini
+++ b/tox.ini
@@ -3,15 +3,23 @@
 # test suite on all supported python versions. To use it, "pip install tox"
 # and then run "tox" from this directory.
 #
-# To run tests against numpy and pandas, run "tox -e py27-extra,py33-extra"
+# To run tests against numpy and pandas, run "tox -e py39-extra,py310-extra"
 # from this directory. This will create a much bigger virtual environments
 # for testing and it is disabled by default.
 
 [tox]
-envlist = lint, py27, py35, py36, py37, py38, py39, py310
+envlist = lint, py{38, 39, 310, 311, 312}
+isolated_build = True
+
+[gh]
+python =
+    3.9: py39-extra
+    3.10: py310-extra
+    3.11: py311-extra
+    3.12: py312-extra
 
 [testenv]
-commands = pytest -v --doctest-modules --ignore benchmark.py
+commands = pytest -v --doctest-modules --ignore benchmark.py {posargs}
 deps =
     pytest
 passenv =
@@ -24,26 +32,15 @@ commands = python -m pre_commit run -a
 deps =
     pre-commit
 
-[testenv:py27-extra]
-basepython = python2.7
-commands = pytest -v --doctest-modules --ignore benchmark.py
-deps =
-    pytest
-    numpy
-    pandas
-    wcwidth
-
-
-[testenv:py35]
-basepython = python3.5
-commands = pytest -v --doctest-modules --ignore benchmark.py
+[testenv:py38]
+basepython = python3.8
+commands = pytest -v --doctest-modules --ignore benchmark.py {posargs}
 deps =
     pytest
 
-
-[testenv:py35-extra]
-basepython = python3.5
-commands = pytest -v --doctest-modules --ignore benchmark.py
+[testenv:py38-extra]
+basepython = python3.8
+commands = pytest -v --doctest-modules --ignore benchmark.py {posargs}
 deps =
     pytest
     numpy
@@ -51,46 +48,32 @@ deps =
     wcwidth
 
 
-[testenv:py36]
-basepython = python3.6
-commands = pytest -v --doctest-modules --ignore benchmark.py
+[testenv:py39]
+basepython = python3.9
+commands = pytest -v --doctest-modules --ignore benchmark.py {posargs}
 deps =
     pytest
 
-
-[testenv:py36-extra]
-basepython = python3.6
-commands = pytest -v --doctest-modules --ignore benchmark.py
+[testenv:py39-extra]
+basepython = python3.9
+commands = pytest -v --doctest-modules --ignore benchmark.py {posargs}
 deps =
     pytest
     numpy
     pandas
     wcwidth
 
-[testenv:py37]
-basepython = python3.7
-commands = pytest -v --doctest-modules --ignore benchmark.py
-deps =
-    pytest
-
-[testenv:py37-extra]
-basepython = python3.7
-commands = pytest -v --doctest-modules --ignore benchmark.py
-deps =
-    pytest
-    numpy
-    pandas
-    wcwidth
 
-[testenv:py38]
-basepython = python3.8
-commands = pytest -v --doctest-modules --ignore benchmark.py
+[testenv:py310]
+basepython = python3.10
+commands = pytest -v --doctest-modules --ignore benchmark.py {posargs}
 deps =
     pytest
 
-[testenv:py38-extra]
-basepython = python3.8
-commands = pytest -v --doctest-modules --ignore benchmark.py
+[testenv:py310-extra]
+basepython = python3.10
+setenv = PYTHONDEVMODE = 1
+commands = pytest -v --doctest-modules --ignore benchmark.py {posargs}
 deps =
     pytest
     numpy
@@ -98,39 +81,38 @@ deps =
     wcwidth
 
 
-[testenv:py39]
-basepython = python3.9
-commands = pytest -v --doctest-modules --ignore benchmark.py
+[testenv:py311]
+basepython = python3.11
+commands = pytest -v --doctest-modules --ignore benchmark.py {posargs}
 deps =
     pytest
 
-[testenv:py39-extra]
-basepython = python3.9
-commands = pytest -v --doctest-modules --ignore benchmark.py
+[testenv:py311-extra]
+basepython = python3.11
+setenv = PYTHONDEVMODE = 1
+commands = pytest -v --doctest-modules --ignore benchmark.py {posargs}
 deps =
     pytest
     numpy
     pandas
     wcwidth
 
-
-[testenv:py310]
-basepython = python3.10
-commands = pytest -v --doctest-modules --ignore benchmark.py
+[testenv:py312]
+basepython = python3.12
+commands = pytest -v --doctest-modules --ignore benchmark.py {posargs}
 deps =
     pytest
 
-[testenv:py310-extra]
-basepython = python3.10
+[testenv:py312-extra]
+basepython = python3.12
 setenv = PYTHONDEVMODE = 1
-commands = pytest -v --doctest-modules --ignore benchmark.py
+commands = pytest -v --doctest-modules --ignore benchmark.py {posargs}
 deps =
     pytest
     numpy
     pandas
     wcwidth
 
-
 [flake8]
 max-complexity = 22
 max-line-length = 99


Test patch:
diff --git a/test/common.py b/test/common.py
index 10fa89e..ca5a069 100644
--- a/test/common.py
+++ b/test/common.py
@@ -1,16 +1,43 @@
 import pytest  # noqa
 from pytest import skip, raises  # noqa
-
+import warnings
 
 def assert_equal(expected, result):
     print("Expected:\n%s\n" % expected)
     print("Got:\n%s\n" % result)
     assert expected == result
 
-
 def assert_in(result, expected_set):
     nums = range(1, len(expected_set) + 1)
     for i, expected in zip(nums, expected_set):
         print("Expected %d:\n%s\n" % (i, expected))
     print("Got:\n%s\n" % result)
     assert result in expected_set
+
+
+def cols_to_pipe_str(cols):
+    return "|".join([str(col) for col in cols])
+
+
+def rows_to_pipe_table_str(rows):
+    lines = []
+    for row in rows:
+        line = cols_to_pipe_str(row)
+        lines.append(line)
+
+    return "\n".join(lines)
+
+
+def check_warnings(func_args_kwargs, *, num=None, category=None, contain=None):
+    func, args, kwargs = func_args_kwargs
+    with warnings.catch_warnings(record=True) as W:
+        # Causes all warnings to always be triggered inside here.
+        warnings.simplefilter("always")
+        func(*args, **kwargs)
+        # Checks
+        if num is not None:
+            assert len(W) == num
+        if category is not None:
+            assert all([issubclass(w.category, category) for w in W])
+        if contain is not None:
+            assert all([contain in str(w.message) for w in W])
diff --git a/test/test_api.py b/test/test_api.py
index bf56e82..e658e82 100644
--- a/test/test_api.py
+++ b/test/test_api.py
@@ -2,19 +2,12 @@
 
 """
 
-from __future__ import print_function
-from __future__ import unicode_literals
 from tabulate import tabulate, tabulate_formats, simple_separated_format
-from platform import python_version_tuple
 from common import skip
 
 
 try:
-    if python_version_tuple() >= ("3", "3", "0"):
-        from inspect import signature, _empty
-    else:
-        signature = None
-        _empty = None
+    from inspect import signature, _empty
 except ImportError:
     signature = None
     _empty = None
@@ -26,14 +19,17 @@ def test_tabulate_formats():
     print("tabulate_formats = %r" % supported)
     assert type(supported) is list
     for fmt in supported:
-        assert type(fmt) is type("")  # noqa
+        assert type(fmt) is str  # noqa
 
 
 def _check_signature(function, expected_sig):
     if not signature:
         skip("")
     actual_sig = signature(function)
-    print("expected: %s\nactual: %s\n" % (expected_sig, str(actual_sig)))
+    print(f"expected: {expected_sig}\nactual: {str(actual_sig)}\n")
+
+    assert len(actual_sig.parameters) == len(expected_sig)
+
     for (e, ev), (a, av) in zip(expected_sig, actual_sig.parameters.items()):
         assert e == a and ev == av.default
 
@@ -46,9 +42,19 @@ def test_tabulate_signature():
         ("headers", ()),
         ("tablefmt", "simple"),
         ("floatfmt", "g"),
+        ("intfmt", ""),
         ("numalign", "default"),
         ("stralign", "default"),
         ("missingval", ""),
+        ("showindex", "default"),
+        ("disable_numparse", False),
+        ("colglobalalign", None),
+        ("colalign", None),
+        ("maxcolwidths", None),
+        ("headersglobalalign", None),
+        ("headersalign", None),
+        ("rowalign", None),
+        ("maxheadercolwidths", None),
     ]
     _check_signature(tabulate, expected_sig)
 
diff --git a/test/test_cli.py b/test/test_cli.py
index 36853fd..e71572d 100644
--- a/test/test_cli.py
+++ b/test/test_cli.py
@@ -2,9 +2,6 @@
 
 """
 
-
-from __future__ import print_function
-from __future__ import unicode_literals
 import os
 import sys
 
@@ -93,11 +90,11 @@ def run_and_capture_stdout(cmd, input=None):
     out, err = x.communicate(input=input_buf)
     out = out.decode("utf-8")
     if x.returncode != 0:
-        raise IOError(err)
+        raise OSError(err)
     return out
 
 
-class TemporaryTextFile(object):
+class TemporaryTextFile:
     def __init__(self):
         self.tmpfile = None
 
@@ -115,7 +112,7 @@ def __exit__(self, exc_type, exc_val, exc_tb):
 
 def test_script_from_stdin_to_stdout():
     """Command line utility: read from stdin, print to stdout"""
-    cmd = [sys.executable, "tabulate.py"]
+    cmd = [sys.executable, "tabulate/__init__.py"]
     out = run_and_capture_stdout(cmd, input=sample_input())
     expected = SAMPLE_SIMPLE_FORMAT
     print("got:     ", repr(out))
@@ -128,7 +125,7 @@ def test_script_from_file_to_stdout():
     with TemporaryTextFile() as tmpfile:
         tmpfile.write(sample_input())
         tmpfile.seek(0)
-        cmd = [sys.executable, "tabulate.py", tmpfile.name]
+        cmd = [sys.executable, "tabulate/__init__.py", tmpfile.name]
         out = run_and_capture_stdout(cmd)
         expected = SAMPLE_SIMPLE_FORMAT
         print("got:     ", repr(out))
@@ -144,7 +141,7 @@ def test_script_from_file_to_file():
             input_file.seek(0)
             cmd = [
                 sys.executable,
-                "tabulate.py",
+                "tabulate/__init__.py",
                 "-o",
                 output_file.name,
                 input_file.name,
@@ -167,7 +164,7 @@ def test_script_from_file_to_file():
 def test_script_header_option():
     """Command line utility: -1, --header option"""
     for option in ["-1", "--header"]:
-        cmd = [sys.executable, "tabulate.py", option]
+        cmd = [sys.executable, "tabulate/__init__.py", option]
         raw_table = sample_input(with_headers=True)
         out = run_and_capture_stdout(cmd, input=raw_table)
         expected = SAMPLE_SIMPLE_FORMAT_WITH_HEADERS
@@ -180,7 +177,7 @@ def test_script_header_option():
 def test_script_sep_option():
     """Command line utility: -s, --sep option"""
     for option in ["-s", "--sep"]:
-        cmd = [sys.executable, "tabulate.py", option, ","]
+        cmd = [sys.executable, "tabulate/__init__.py", option, ","]
         raw_table = sample_input(sep=",")
         out = run_and_capture_stdout(cmd, input=raw_table)
         expected = SAMPLE_SIMPLE_FORMAT
@@ -192,7 +189,14 @@ def test_script_sep_option():
 def test_script_floatfmt_option():
     """Command line utility: -F, --float option"""
     for option in ["-F", "--float"]:
-        cmd = [sys.executable, "tabulate.py", option, ".1e", "--format", "grid"]
+        cmd = [
+            sys.executable,
+            "tabulate/__init__.py",
+            option,
+            ".1e",
+            "--format",
+            "grid",
+        ]
         raw_table = sample_input()
         out = run_and_capture_stdout(cmd, input=raw_table)
         expected = SAMPLE_GRID_FORMAT_WITH_DOT1E_FLOATS
@@ -204,7 +208,7 @@ def test_script_floatfmt_option():
 def test_script_format_option():
     """Command line utility: -f, --format option"""
     for option in ["-f", "--format"]:
-        cmd = [sys.executable, "tabulate.py", "-1", option, "grid"]
+        cmd = [sys.executable, "tabulate/__init__.py", "-1", option, "grid"]
         raw_table = sample_input(with_headers=True)
         out = run_and_capture_stdout(cmd, input=raw_table)
         expected = SAMPLE_GRID_FORMAT_WITH_HEADERS
diff --git a/test/test_input.py b/test/test_input.py
index 541b4f7..721d03a 100644
--- a/test/test_input.py
+++ b/test/test_input.py
@@ -1,9 +1,5 @@
-# -*- coding: utf-8 -*-
-
 """Test support of the various forms of tabular data."""
 
-from __future__ import print_function
-from __future__ import unicode_literals
 from tabulate import tabulate
 from common import assert_equal, assert_in, raises, skip
 
@@ -471,7 +467,7 @@ def test_list_of_dicts_with_list_of_headers():
         tabulate(table, headers=headers)
 
 
-def test_py27orlater_list_of_ordereddicts():
+def test_list_of_ordereddicts():
     "Input: a list of OrderedDicts."
     from collections import OrderedDict
 
@@ -480,3 +476,55 @@ def test_py27orlater_list_of_ordereddicts():
     expected = "\n".join(["  b    a", "---  ---", "  1    2", "  1    2"])
     result = tabulate(lod, headers="keys")
     assert_equal(expected, result)
+
+
+def test_py37orlater_list_of_dataclasses_keys():
+    "Input: a list of dataclasses with first item's fields as keys and headers"
+    try:
+        from dataclasses import make_dataclass
+
+        Person = make_dataclass("Person", ["name", "age", "height"])
+        ld = [Person("Alice", 23, 169.5), Person("Bob", 27, 175.0)]
+        result = tabulate(ld, headers="keys")
+        expected = "\n".join(
+            [
+                "name      age    height",
+                "------  -----  --------",
+                "Alice      23     169.5",
+                "Bob        27     175",
+            ]
+        )
+        assert_equal(expected, result)
+    except ImportError:
+        skip("test_py37orlater_list_of_dataclasses_keys is skipped")
+
+
+def test_py37orlater_list_of_dataclasses_headers():
+    "Input: a list of dataclasses with user-supplied headers"
+    try:
+        from dataclasses import make_dataclass
+
+        Person = make_dataclass("Person", ["name", "age", "height"])
+        ld = [Person("Alice", 23, 169.5), Person("Bob", 27, 175.0)]
+        result = tabulate(ld, headers=["person", "years", "cm"])
+        expected = "\n".join(
+            [
+                "person      years     cm",
+                "--------  -------  -----",
+                "Alice          23  169.5",
+                "Bob            27  175",
+            ]
+        )
+        assert_equal(expected, result)
+    except ImportError:
+        skip("test_py37orlater_list_of_dataclasses_headers is skipped")
+
+
+def test_list_bytes():
+    "Input: a list of bytes. (issue #192)"
+    lb = [["你好".encode()], ["你好"]]
+    expected = "\n".join(
+        ["bytes", "---------------------------", r"b'\xe4\xbd\xa0\xe5\xa5\xbd'", "你好"]
+    )
+    result = tabulate(lb, headers=["bytes"])
+    assert_equal(expected, result)
diff --git a/test/test_internal.py b/test/test_internal.py
index 035558f..e7564d3 100644
--- a/test/test_internal.py
+++ b/test/test_internal.py
@@ -1,11 +1,8 @@
-# -*- coding: utf-8 -*-
-
 """Tests of the internal tabulate functions."""
 
-from __future__ import print_function
-from __future__ import unicode_literals
 import tabulate as T
-from common import assert_equal
+
+from common import assert_equal, skip, rows_to_pipe_table_str, cols_to_pipe_str
 
 
 def test_multiline_width():
@@ -19,7 +16,7 @@ def test_multiline_width():
 def test_align_column_decimal():
     "Internal: _align_column(..., 'decimal')"
     column = ["12.345", "-1234.5", "1.23", "1234.5", "1e+234", "1.0e234"]
-    output = T._align_column(column, "decimal")
+    result = T._align_column(column, "decimal")
     expected = [
         "   12.345  ",
         "-1234.5    ",
@@ -28,7 +25,37 @@ def test_align_column_decimal():
         "    1e+234 ",
         "    1.0e234",
     ]
-    assert_equal(output, expected)
+    assert_equal(expected, result)
+
+
+def test_align_column_decimal_with_thousand_separators():
+    "Internal: _align_column(..., 'decimal')"
+    column = ["12.345", "-1234.5", "1.23", "1,234.5", "1e+234", "1.0e234"]
+    output = T._align_column(column, "decimal")
+    expected = [
+        "   12.345  ",
+        "-1234.5    ",
+        "    1.23   ",
+        "1,234.5    ",
+        "    1e+234 ",
+        "    1.0e234",
+    ]
+    assert_equal(expected, output)
+
+
+def test_align_column_decimal_with_incorrect_thousand_separators():
+    "Internal: _align_column(..., 'decimal')"
+    column = ["12.345", "-1234.5", "1.23", "12,34.5", "1e+234", "1.0e234"]
+    output = T._align_column(column, "decimal")
+    expected = [
+        "     12.345  ",
+        "  -1234.5    ",
+        "      1.23   ",
+        "12,34.5      ",
+        "      1e+234 ",
+        "      1.0e234",
+    ]
+    assert_equal(expected, output)
 
 
 def test_align_column_none():
@@ -36,7 +63,7 @@ def test_align_column_none():
     column = ["123.4", "56.7890"]
     output = T._align_column(column, None)
     expected = ["123.4", "56.7890"]
-    assert_equal(output, expected)
+    assert_equal(expected, output)
 
 
 def test_align_column_multiline():
@@ -44,4 +71,280 @@ def test_align_column_multiline():
     column = ["1", "123", "12345\n6"]
     output = T._align_column(column, "center", is_multiline=True)
     expected = ["  1  ", " 123 ", "12345" + "\n" + "  6  "]
-    assert_equal(output, expected)
+    assert_equal(expected, output)
+
+
+def test_align_cell_veritically_one_line_only():
+    "Internal: Aligning a single height cell is same regardless of alignment value"
+    lines = ["one line"]
+    column_width = 8
+
+    top = T._align_cell_veritically(lines, 1, column_width, "top")
+    center = T._align_cell_veritically(lines, 1, column_width, "center")
+    bottom = T._align_cell_veritically(lines, 1, column_width, "bottom")
+    none = T._align_cell_veritically(lines, 1, column_width, None)
+
+    expected = ["one line"]
+    assert top == center == bottom == none == expected
+
+
+def test_align_cell_veritically_top_single_text_multiple_pad():
+    "Internal: Align single cell text to top"
+    result = T._align_cell_veritically(["one line"], 3, 8, "top")
+
+    expected = ["one line", "        ", "        "]
+
+    assert_equal(expected, result)
+
+
+def test_align_cell_veritically_center_single_text_multiple_pad():
+    "Internal: Align single cell text to center"
+    result = T._align_cell_veritically(["one line"], 3, 8, "center")
+
+    expected = ["        ", "one line", "        "]
+
+    assert_equal(expected, result)
+
+
+def test_align_cell_veritically_bottom_single_text_multiple_pad():
+    "Internal: Align single cell text to bottom"
+    result = T._align_cell_veritically(["one line"], 3, 8, "bottom")
+
+    expected = ["        ", "        ", "one line"]
+
+    assert_equal(expected, result)
+
+
+def test_align_cell_veritically_top_multi_text_multiple_pad():
+    "Internal: Align multiline celltext text to top"
+    text = ["just", "one ", "cell"]
+    result = T._align_cell_veritically(text, 6, 4, "top")
+
+    expected = ["just", "one ", "cell", "    ", "    ", "    "]
+
+    assert_equal(expected, result)
+
+
+def test_align_cell_veritically_center_multi_text_multiple_pad():
+    "Internal: Align multiline celltext text to center"
+    text = ["just", "one ", "cell"]
+    result = T._align_cell_veritically(text, 6, 4, "center")
+
+    # Even number of rows, can't perfectly center, but we pad less
+    # at top when required to do make a judgement
+    expected = ["    ", "just", "one ", "cell", "    ", "    "]
+
+    assert_equal(expected, result)
+
+
+def test_align_cell_veritically_bottom_multi_text_multiple_pad():
+    "Internal: Align multiline celltext text to bottom"
+    text = ["just", "one ", "cell"]
+    result = T._align_cell_veritically(text, 6, 4, "bottom")
+
+    expected = ["    ", "    ", "    ", "just", "one ", "cell"]
+
+    assert_equal(expected, result)
+
+
+def test_wrap_text_to_colwidths():
+    "Internal: Test _wrap_text_to_colwidths to show it will wrap text based on colwidths"
+    rows = [
+        ["mini", "medium", "decently long", "wrap will be ignored"],
+        [
+            "small",
+            "JustOneWordThatIsWayTooLong",
+            "this is unreasonably long for a single cell length",
+            "also ignored here",
+        ],
+    ]
+    widths = [10, 10, 20, None]
+    expected = [
+        ["mini", "medium", "decently long", "wrap will be ignored"],
+        [
+            "small",
+            "JustOneWor\ndThatIsWay\nTooLong",
+            "this is unreasonably\nlong for a single\ncell length",
+            "also ignored here",
+        ],
+    ]
+    result = T._wrap_text_to_colwidths(rows, widths)
+
+    assert_equal(expected, result)
+
+
+def test_wrap_text_wide_chars():
+    "Internal: Wrap wide characters based on column width"
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_wrap_text_wide_chars is skipped")
+
+    rows = [["청자청자청자청자청자", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다"]]
+    widths = [5, 20]
+    expected = [
+        [
+            "청자\n청자\n청자\n청자\n청자",
+            "약간 감싸면 더 잘\n보일 수있는 다소 긴\n설명입니다",
+        ]
+    ]
+    result = T._wrap_text_to_colwidths(rows, widths)
+
+    assert_equal(expected, result)
+
+
+def test_wrap_text_to_numbers():
+    """Internal: Test _wrap_text_to_colwidths force ignores numbers by
+    default so as not to break alignment behaviors"""
+    rows = [
+        ["first number", 123.456789, "123.456789"],
+        ["second number", "987654.123", "987654.123"],
+    ]
+    widths = [6, 6, 6]
+    expected = [
+        ["first\nnumber", 123.456789, "123.45\n6789"],
+        ["second\nnumber", "987654.123", "987654\n.123"],
+    ]
+
+    result = T._wrap_text_to_colwidths(rows, widths, numparses=[True, True, False])
+    assert_equal(expected, result)
+
+
+def test_wrap_text_to_colwidths_single_ansi_colors_full_cell():
+    """Internal: autowrapped text can retain a single ANSI colors
+    when it is at the beginning and end of full cell"""
+    data = [
+        [
+            (
+                "\033[31mThis is a rather long description that might"
+                " look better if it is wrapped a bit\033[0m"
+            )
+        ]
+    ]
+    result = T._wrap_text_to_colwidths(data, [30])
+
+    expected = [
+        [
+            "\n".join(
+                [
+                    "\033[31mThis is a rather long\033[0m",
+                    "\033[31mdescription that might look\033[0m",
+                    "\033[31mbetter if it is wrapped a bit\033[0m",
+                ]
+            )
+        ]
+    ]
+    assert_equal(expected, result)
+
+
+def test_wrap_text_to_colwidths_colors_wide_char():
+    """Internal: autowrapped text can retain a ANSI colors with wide chars"""
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_wrap_text_to_colwidths_colors_wide_char is skipped")
+
+    data = [[("\033[31m약간 감싸면 더 잘 보일 수있는 다소 긴" " 설명입니다 설명입니다 설명입니다 설명입니다 설명\033[0m")]]
+    result = T._wrap_text_to_colwidths(data, [30])
+
+    expected = [
+        [
+            "\n".join(
+                [
+                    "\033[31m약간 감싸면 더 잘 보일 수있는\033[0m",
+                    "\033[31m다소 긴 설명입니다 설명입니다\033[0m",
+                    "\033[31m설명입니다 설명입니다 설명\033[0m",
+                ]
+            )
+        ]
+    ]
+    assert_equal(expected, result)
+
+
+def test_wrap_text_to_colwidths_multi_ansi_colors_full_cell():
+    """Internal: autowrapped text can retain multiple ANSI colors
+    when they are at the beginning and end of full cell
+    (e.g. text and background colors)"""
+    data = [
+        [
+            (
+                "\033[31m\033[43mThis is a rather long description that"
+                " might look better if it is wrapped a bit\033[0m"
+            )
+        ]
+    ]
+    result = T._wrap_text_to_colwidths(data, [30])
+
+    expected = [
+        [
+            "\n".join(
+                [
+                    "\033[31m\033[43mThis is a rather long\033[0m",
+                    "\033[31m\033[43mdescription that might look\033[0m",
+                    "\033[31m\033[43mbetter if it is wrapped a bit\033[0m",
+                ]
+            )
+        ]
+    ]
+    assert_equal(expected, result)
+
+
+def test_wrap_text_to_colwidths_multi_ansi_colors_in_subset():
+    """Internal: autowrapped text can retain multiple ANSI colors
+    when they are around subsets of the cell"""
+    data = [
+        [
+            (
+                "This is a rather \033[31mlong description\033[0m that"
+                " might look better \033[93mif it is wrapped\033[0m a bit"
+            )
+        ]
+    ]
+    result = T._wrap_text_to_colwidths(data, [30])
+
+    expected = [
+        [
+            "\n".join(
+                [
+                    "This is a rather \033[31mlong\033[0m",
+                    "\033[31mdescription\033[0m that might look",
+                    "better \033[93mif it is wrapped\033[0m a bit",
+                ]
+            )
+        ]
+    ]
+    assert_equal(expected, result)
+
+
+def test__remove_separating_lines():
+    with_rows = [
+        [0, "a"],
+        [1, "b"],
+        T.SEPARATING_LINE,
+        [2, "c"],
+        T.SEPARATING_LINE,
+        [3, "c"],
+        T.SEPARATING_LINE,
+    ]
+    result, sep_lines = T._remove_separating_lines(with_rows)
+    expected = rows_to_pipe_table_str([[0, "a"], [1, "b"], [2, "c"], [3, "c"]])
+
+    assert_equal(expected, rows_to_pipe_table_str(result))
+    assert_equal("2|4|6", cols_to_pipe_str(sep_lines))
+
+
+def test__reinsert_separating_lines():
+    with_rows = [
+        [0, "a"],
+        [1, "b"],
+        T.SEPARATING_LINE,
+        [2, "c"],
+        T.SEPARATING_LINE,
+        [3, "c"],
+        T.SEPARATING_LINE,
+    ]
+    sans_rows, sep_lines = T._remove_separating_lines(with_rows)
+    T._reinsert_separating_lines(sans_rows, sep_lines)
+    expected = rows_to_pipe_table_str(with_rows)
+
+    assert_equal(expected, rows_to_pipe_table_str(sans_rows))
diff --git a/test/test_output.py b/test/test_output.py
index abab439..c90f90c 100644
--- a/test/test_output.py
+++ b/test/test_output.py
@@ -1,19 +1,16 @@
-# -*- coding: utf-8 -*-
-
 """Test output of the various forms of tabular data."""
+from pytest import mark
 
-from __future__ import print_function
-from __future__ import unicode_literals
 import tabulate as tabulate_module
-from tabulate import tabulate, simple_separated_format
-from common import assert_equal, raises, skip
-
+from common import assert_equal, raises, skip, check_warnings
+from tabulate import tabulate, simple_separated_format, SEPARATING_LINE
 
 # _test_table shows
 #  - coercion of a string to a number,
 #  - left alignment of text,
 #  - decimal point alignment of numbers
 _test_table = [["spam", 41.9999], ["eggs", "451.0"]]
+_test_table_with_sep_line = [["spam", 41.9999], SEPARATING_LINE, ["eggs", "451.0"]]
 _test_table_headers = ["strings", "numbers"]
 
 
@@ -104,6 +101,137 @@ def test_plain_multiline_with_empty_cells_headerless():
     assert_equal(expected, result)
 
 
+def test_plain_maxcolwidth_autowraps():
+    "Output: maxcolwidth will result in autowrapping longer cells"
+    table = [["hdr", "fold"], ["1", "very long data"]]
+    expected = "\n".join(["  hdr  fold", "    1  very long", "       data"])
+    result = tabulate(
+        table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 10]
+    )
+    assert_equal(expected, result)
+
+
+def test_plain_maxcolwidth_autowraps_with_sep():
+    "Output: maxcolwidth will result in autowrapping longer cells and separating line"
+    table = [
+        ["hdr", "fold"],
+        ["1", "very long data"],
+        SEPARATING_LINE,
+        ["2", "last line"],
+    ]
+    expected = "\n".join(
+        ["  hdr  fold", "    1  very long", "       data", "", "    2  last line"]
+    )
+    result = tabulate(
+        table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 10]
+    )
+    assert_equal(expected, result)
+
+
+def test_plain_maxcolwidth_autowraps_wide_chars():
+    "Output: maxcolwidth and autowrapping functions with wide characters"
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_wrap_text_wide_chars is skipped")
+
+    table = [
+        ["hdr", "fold"],
+        [
+            "1",
+            "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다 설명입니다 설명입니다 설명입니다 설명",
+        ],
+    ]
+    expected = "\n".join(
+        [
+            "  hdr  fold",
+            "    1  약간 감싸면 더 잘 보일 수있는",
+            "       다소 긴 설명입니다 설명입니다",
+            "       설명입니다 설명입니다 설명",
+        ]
+    )
+    result = tabulate(
+        table, headers="firstrow", tablefmt="plain", maxcolwidths=[10, 30]
+    )
+    assert_equal(expected, result)
+
+
+def test_maxcolwidth_single_value():
+    "Output: maxcolwidth can be specified as a single number that works for each column"
+    table = [
+        ["hdr", "fold1", "fold2"],
+        ["mini", "this is short", "this is a bit longer"],
+    ]
+    expected = "\n".join(
+        [
+            "hdr    fold1    fold2",
+            "mini   this     this",
+            "       is       is a",
+            "       short    bit",
+            "                longer",
+        ]
+    )
+    result = tabulate(table, headers="firstrow", tablefmt="plain", maxcolwidths=6)
+    assert_equal(expected, result)
+
+
+def test_maxcolwidth_pad_tailing_widths():
+    "Output: maxcolwidth, if only partly specified, pads tailing cols with None"
+    table = [
+        ["hdr", "fold1", "fold2"],
+        ["mini", "this is short", "this is a bit longer"],
+    ]
+    expected = "\n".join(
+        [
+            "hdr    fold1    fold2",
+            "mini   this     this is a bit longer",
+            "       is",
+            "       short",
+        ]
+    )
+    result = tabulate(
+        table, headers="firstrow", tablefmt="plain", maxcolwidths=[None, 6]
+    )
+    assert_equal(expected, result)
+
+
+def test_maxcolwidth_honor_disable_parsenum():
+    "Output: Using maxcolwidth in conjunction with disable_parsenum is honored"
+    table = [
+        ["first number", 123.456789, "123.456789"],
+        ["second number", "987654321.123", "987654321.123"],
+    ]
+    expected = "\n".join(
+        [
+            "+--------+---------------+--------+",
+            "| first  | 123.457       | 123.45 |",
+            "| number |               | 6789   |",
+            "+--------+---------------+--------+",
+            "| second |   9.87654e+08 | 987654 |",
+            "| number |               | 321.12 |",
+            "|        |               | 3      |",
+            "+--------+---------------+--------+",
+        ]
+    )
+    # Grid makes showing the alignment difference a little easier
+    result = tabulate(table, tablefmt="grid", maxcolwidths=6, disable_numparse=[2])
+    assert_equal(expected, result)
+
+
+def test_plain_maxheadercolwidths_autowraps():
+    "Output: maxheadercolwidths will result in autowrapping header cell"
+    table = [["hdr", "fold"], ["1", "very long data"]]
+    expected = "\n".join(["  hdr  fo", "       ld", "    1  very long", "       data"])
+    result = tabulate(
+        table,
+        headers="firstrow",
+        tablefmt="plain",
+        maxcolwidths=[10, 10],
+        maxheadercolwidths=[None, 2],
+    )
+    assert_equal(expected, result)
+
+
 def test_simple():
     "Output: simple with headers"
     expected = "\n".join(
@@ -118,6 +246,37 @@ def test_simple():
     assert_equal(expected, result)
 
 
+def test_simple_with_sep_line():
+    "Output: simple with headers and separating line"
+    expected = "\n".join(
+        [
+            "strings      numbers",
+            "---------  ---------",
+            "spam         41.9999",
+            "---------  ---------",
+            "eggs        451",
+        ]
+    )
+    result = tabulate(_test_table_with_sep_line, _test_table_headers, tablefmt="simple")
+    assert_equal(expected, result)
+
+
+def test_readme_example_with_sep():
+    table = [["Earth", 6371], ["Mars", 3390], SEPARATING_LINE, ["Moon", 1737]]
+    expected = "\n".join(
+        [
+            "-----  ----",
+            "Earth  6371",
+            "Mars   3390",
+            "-----  ----",
+            "Moon   1737",
+            "-----  ----",
+        ]
+    )
+    result = tabulate(table, tablefmt="simple")
+    assert_equal(expected, result)
+
+
 def test_simple_multiline_2():
     "Output: simple with multiline cells"
     expected = "\n".join(
@@ -134,6 +293,28 @@ def test_simple_multiline_2():
     assert_equal(expected, result)
 
 
+def test_simple_multiline_2_with_sep_line():
+    "Output: simple with multiline cells"
+    expected = "\n".join(
+        [
+            " key     value",
+            "-----  ---------",
+            " foo      bar",
+            "-----  ---------",
+            "spam   multiline",
+            "         world",
+        ]
+    )
+    table = [
+        ["key", "value"],
+        ["foo", "bar"],
+        SEPARATING_LINE,
+        ["spam", "multiline\nworld"],
+    ]
+    result = tabulate(table, headers="firstrow", stralign="center", tablefmt="simple")
+    assert_equal(expected, result)
+
+
 def test_simple_headerless():
     "Output: simple without headers"
     expected = "\n".join(
@@ -143,6 +324,51 @@ def test_simple_headerless():
     assert_equal(expected, result)
 
 
+def test_simple_headerless_with_sep_line():
+    "Output: simple without headers"
+    expected = "\n".join(
+        [
+            "----  --------",
+            "spam   41.9999",
+            "----  --------",
+            "eggs  451",
+            "----  --------",
+        ]
+    )
+    result = tabulate(_test_table_with_sep_line, tablefmt="simple")
+    assert_equal(expected, result)
+
+
+def test_simple_headerless_with_sep_line_with_padding_in_tablefmt():
+    "Output: simple without headers with sep line with padding in tablefmt"
+    expected = "\n".join(
+        [
+            "|------|----------|",
+            "| spam |  41.9999 |",
+            "|------|----------|",
+            "| eggs | 451      |",
+        ]
+    )
+    result = tabulate(_test_table_with_sep_line, tablefmt="github")
+    assert_equal(expected, result)
+
+
+def test_simple_headerless_with_sep_line_with_linebetweenrows_in_tablefmt():
+    "Output: simple without headers with sep line with linebetweenrows in tablefmt"
+    expected = "\n".join(
+        [
+            "+------+----------+",
+            "| spam |  41.9999 |",
+            "+------+----------+",
+            "+------+----------+",
+            "| eggs | 451      |",
+            "+------+----------+",
+        ]
+    )
+    result = tabulate(_test_table_with_sep_line, tablefmt="grid")
+    assert_equal(expected, result)
+
+
 def test_simple_multiline_headerless():
     "Output: simple with multiline cells without headers"
     table = [["foo bar\nbaz\nbau", "hello"], ["", "multiline\nworld"]]
@@ -282,214 +508,1398 @@ def test_grid_wide_characters():
             "+-----------+----------+",
         ]
     )
-    result = tabulate(_test_table, headers, tablefmt="grid")
+    result = tabulate(_test_table, headers, tablefmt="grid")
+    assert_equal(expected, result)
+
+
+def test_grid_headerless():
+    "Output: grid without headers"
+    expected = "\n".join(
+        [
+            "+------+----------+",
+            "| spam |  41.9999 |",
+            "+------+----------+",
+            "| eggs | 451      |",
+            "+------+----------+",
+        ]
+    )
+    result = tabulate(_test_table, tablefmt="grid")
+    assert_equal(expected, result)
+
+
+def test_grid_multiline_headerless():
+    "Output: grid with multiline cells without headers"
+    table = [["foo bar\nbaz\nbau", "hello"], ["", "multiline\nworld"]]
+    expected = "\n".join(
+        [
+            "+---------+-----------+",
+            "| foo bar |   hello   |",
+            "|   baz   |           |",
+            "|   bau   |           |",
+            "+---------+-----------+",
+            "|         | multiline |",
+            "|         |   world   |",
+            "+---------+-----------+",
+        ]
+    )
+    result = tabulate(table, stralign="center", tablefmt="grid")
+    assert_equal(expected, result)
+
+
+def test_grid_multiline():
+    "Output: grid with multiline cells with headers"
+    table = [[2, "foo\nbar"]]
+    headers = ("more\nspam \x1b[31meggs\x1b[0m", "more spam\n& eggs")
+    expected = "\n".join(
+        [
+            "+-------------+-------------+",
+            "|        more | more spam   |",
+            "|   spam \x1b[31meggs\x1b[0m | & eggs      |",
+            "+=============+=============+",
+            "|           2 | foo         |",
+            "|             | bar         |",
+            "+-------------+-------------+",
+        ]
+    )
+    result = tabulate(table, headers, tablefmt="grid")
+    assert_equal(expected, result)
+
+
+def test_grid_multiline_with_empty_cells():
+    "Output: grid with multiline cells and empty cells with headers"
+    table = [
+        ["hdr", "data", "fold"],
+        ["1", "", ""],
+        ["2", "very long data", "fold\nthis"],
+    ]
+    expected = "\n".join(
+        [
+            "+-------+----------------+--------+",
+            "|   hdr | data           | fold   |",
+            "+=======+================+========+",
+            "|     1 |                |        |",
+            "+-------+----------------+--------+",
+            "|     2 | very long data | fold   |",
+            "|       |                | this   |",
+            "+-------+----------------+--------+",
+        ]
+    )
+    result = tabulate(table, headers="firstrow", tablefmt="grid")
+    assert_equal(expected, result)
+
+
+def test_grid_multiline_with_empty_cells_headerless():
+    "Output: grid with multiline cells and empty cells without headers"
+    table = [["0", "", ""], ["1", "", ""], ["2", "very long data", "fold\nthis"]]
+    expected = "\n".join(
+        [
+            "+---+----------------+------+",
+            "| 0 |                |      |",
+            "+---+----------------+------+",
+            "| 1 |                |      |",
+            "+---+----------------+------+",
+            "| 2 | very long data | fold |",
+            "|   |                | this |",
+            "+---+----------------+------+",
+        ]
+    )
+    result = tabulate(table, tablefmt="grid")
+    assert_equal(expected, result)
+
+
+def test_simple_grid():
+    "Output: simple_grid with headers"
+    expected = "\n".join(
+        [
+            "┌───────────┬───────────┐",
+            "│ strings   │   numbers │",
+            "├───────────┼───────────┤",
+            "│ spam      │   41.9999 │",
+            "├───────────┼───────────┤",
+            "│ eggs      │  451      │",
+            "└───────────┴───────────┘",
+        ]
+    )
+    result = tabulate(_test_table, _test_table_headers, tablefmt="simple_grid")
+    assert_equal(expected, result)
+
+
+def test_simple_grid_wide_characters():
+    "Output: simple_grid with wide characters in headers"
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_simple_grid_wide_characters is skipped")
+    headers = list(_test_table_headers)
+    headers[1] = "配列"
+    expected = "\n".join(
+        [
+            "┌───────────┬──────────┐",
+            "│ strings   │     配列 │",
+            "├───────────┼──────────┤",
+            "│ spam      │  41.9999 │",
+            "├───────────┼──────────┤",
+            "│ eggs      │ 451      │",
+            "└───────────┴──────────┘",
+        ]
+    )
+    result = tabulate(_test_table, headers, tablefmt="simple_grid")
+    assert_equal(expected, result)
+
+
+def test_simple_grid_headerless():
+    "Output: simple_grid without headers"
+    expected = "\n".join(
+        [
+            "┌──────┬──────────┐",
+            "│ spam │  41.9999 │",
+            "├──────┼──────────┤",
+            "│ eggs │ 451      │",
+            "└──────┴──────────┘",
+        ]
+    )
+    result = tabulate(_test_table, tablefmt="simple_grid")
+    assert_equal(expected, result)
+
+
+def test_simple_grid_multiline_headerless():
+    "Output: simple_grid with multiline cells without headers"
+    table = [["foo bar\nbaz\nbau", "hello"], ["", "multiline\nworld"]]
+    expected = "\n".join(
+        [
+            "┌─────────┬───────────┐",
+            "│ foo bar │   hello   │",
+            "│   baz   │           │",
+            "│   bau   │           │",
+            "├─────────┼───────────┤",
+            "│         │ multiline │",
+            "│         │   world   │",
+            "└─────────┴───────────┘",
+        ]
+    )
+    result = tabulate(table, stralign="center", tablefmt="simple_grid")
+    assert_equal(expected, result)
+
+
+def test_simple_grid_multiline():
+    "Output: simple_grid with multiline cells with headers"
+    table = [[2, "foo\nbar"]]
+    headers = ("more\nspam \x1b[31meggs\x1b[0m", "more spam\n& eggs")
+    expected = "\n".join(
+        [
+            "┌─────────────┬─────────────┐",
+            "│        more │ more spam   │",
+            "│   spam \x1b[31meggs\x1b[0m │ & eggs      │",
+            "├─────────────┼─────────────┤",
+            "│           2 │ foo         │",
+            "│             │ bar         │",
+            "└─────────────┴─────────────┘",
+        ]
+    )
+    result = tabulate(table, headers, tablefmt="simple_grid")
+    assert_equal(expected, result)
+
+
+def test_simple_grid_multiline_with_empty_cells():
+    "Output: simple_grid with multiline cells and empty cells with headers"
+    table = [
+        ["hdr", "data", "fold"],
+        ["1", "", ""],
+        ["2", "very long data", "fold\nthis"],
+    ]
+    expected = "\n".join(
+        [
+            "┌───────┬────────────────┬────────┐",
+            "│   hdr │ data           │ fold   │",
+            "├───────┼────────────────┼────────┤",
+            "│     1 │                │        │",
+            "├───────┼────────────────┼────────┤",
+            "│     2 │ very long data │ fold   │",
+            "│       │                │ this   │",
+            "└───────┴────────────────┴────────┘",
+        ]
+    )
+    result = tabulate(table, headers="firstrow", tablefmt="simple_grid")
+    assert_equal(expected, result)
+
+
+def test_simple_grid_multiline_with_empty_cells_headerless():
+    "Output: simple_grid with multiline cells and empty cells without headers"
+    table = [["0", "", ""], ["1", "", ""], ["2", "very long data", "fold\nthis"]]
+    expected = "\n".join(
+        [
+            "┌───┬────────────────┬──────┐",
+            "│ 0 │                │      │",
+            "├───┼────────────────┼──────┤",
+            "│ 1 │                │      │",
+            "├───┼────────────────┼──────┤",
+            "│ 2 │ very long data │ fold │",
+            "│   │                │ this │",
+            "└───┴────────────────┴──────┘",
+        ]
+    )
+    result = tabulate(table, tablefmt="simple_grid")
+    assert_equal(expected, result)
+
+
+def test_rounded_grid():
+    "Output: rounded_grid with headers"
+    expected = "\n".join(
+        [
+            "╭───────────┬───────────╮",
+            "│ strings   │   numbers │",
+            "├───────────┼───────────┤",
+            "│ spam      │   41.9999 │",
+            "├───────────┼───────────┤",
+            "│ eggs      │  451      │",
+            "╰───────────┴───────────╯",
+        ]
+    )
+    result = tabulate(_test_table, _test_table_headers, tablefmt="rounded_grid")
+    assert_equal(expected, result)
+
+
+def test_rounded_grid_wide_characters():
+    "Output: rounded_grid with wide characters in headers"
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_rounded_grid_wide_characters is skipped")
+    headers = list(_test_table_headers)
+    headers[1] = "配列"
+    expected = "\n".join(
+        [
+            "╭───────────┬──────────╮",
+            "│ strings   │     配列 │",
+            "├───────────┼──────────┤",
+            "│ spam      │  41.9999 │",
+            "├───────────┼──────────┤",
+            "│ eggs      │ 451      │",
+            "╰───────────┴──────────╯",
+        ]
+    )
+    result = tabulate(_test_table, headers, tablefmt="rounded_grid")
+    assert_equal(expected, result)
+
+
+def test_rounded_grid_headerless():
+    "Output: rounded_grid without headers"
+    expected = "\n".join(
+        [
+            "╭──────┬──────────╮",
+            "│ spam │  41.9999 │",
+            "├──────┼──────────┤",
+            "│ eggs │ 451      │",
+            "╰──────┴──────────╯",
+        ]
+    )
+    result = tabulate(_test_table, tablefmt="rounded_grid")
+    assert_equal(expected, result)
+
+
+def test_rounded_grid_multiline_headerless():
+    "Output: rounded_grid with multiline cells without headers"
+    table = [["foo bar\nbaz\nbau", "hello"], ["", "multiline\nworld"]]
+    expected = "\n".join(
+        [
+            "╭─────────┬───────────╮",
+            "│ foo bar │   hello   │",
+            "│   baz   │           │",
+            "│   bau   │           │",
+            "├─────────┼───────────┤",
+            "│         │ multiline │",
+            "│         │   world   │",
+            "╰─────────┴───────────╯",
+        ]
+    )
+    result = tabulate(table, stralign="center", tablefmt="rounded_grid")
+    assert_equal(expected, result)
+
+
+def test_rounded_grid_multiline():
+    "Output: rounded_grid with multiline cells with headers"
+    table = [[2, "foo\nbar"]]
+    headers = ("more\nspam \x1b[31meggs\x1b[0m", "more spam\n& eggs")
+    expected = "\n".join(
+        [
+            "╭─────────────┬─────────────╮",
+            "│        more │ more spam   │",
+            "│   spam \x1b[31meggs\x1b[0m │ & eggs      │",
+            "├─────────────┼─────────────┤",
+            "│           2 │ foo         │",
+            "│             │ bar         │",
+            "╰─────────────┴─────────────╯",
+        ]
+    )
+    result = tabulate(table, headers, tablefmt="rounded_grid")
+    assert_equal(expected, result)
+
+
+def test_rounded_grid_multiline_with_empty_cells():
+    "Output: rounded_grid with multiline cells and empty cells with headers"
+    table = [
+        ["hdr", "data", "fold"],
+        ["1", "", ""],
+        ["2", "very long data", "fold\nthis"],
+    ]
+    expected = "\n".join(
+        [
+            "╭───────┬────────────────┬────────╮",
+            "│   hdr │ data           │ fold   │",
+            "├───────┼────────────────┼────────┤",
+            "│     1 │                │        │",
+            "├───────┼────────────────┼────────┤",
+            "│     2 │ very long data │ fold   │",
+            "│       │                │ this   │",
+            "╰───────┴────────────────┴────────╯",
+        ]
+    )
+    result = tabulate(table, headers="firstrow", tablefmt="rounded_grid")
+    assert_equal(expected, result)
+
+
+def test_rounded_grid_multiline_with_empty_cells_headerless():
+    "Output: rounded_grid with multiline cells and empty cells without headers"
+    table = [["0", "", ""], ["1", "", ""], ["2", "very long data", "fold\nthis"]]
+    expected = "\n".join(
+        [
+            "╭───┬────────────────┬──────╮",
+            "│ 0 │                │      │",
+            "├───┼────────────────┼──────┤",
+            "│ 1 │                │      │",
+            "├───┼────────────────┼──────┤",
+            "│ 2 │ very long data │ fold │",
+            "│   │                │ this │",
+            "╰───┴────────────────┴──────╯",
+        ]
+    )
+    result = tabulate(table, tablefmt="rounded_grid")
+    assert_equal(expected, result)
+
+
+def test_heavy_grid():
+    "Output: heavy_grid with headers"
+    expected = "\n".join(
+        [
+            "┏━━━━━━━━━━━┳━━━━━━━━━━━┓",
+            "┃ strings   ┃   numbers ┃",
+            "┣━━━━━━━━━━━╋━━━━━━━━━━━┫",
+            "┃ spam      ┃   41.9999 ┃",
+            "┣━━━━━━━━━━━╋━━━━━━━━━━━┫",
+            "┃ eggs      ┃  451      ┃",
+            "┗━━━━━━━━━━━┻━━━━━━━━━━━┛",
+        ]
+    )
+    result = tabulate(_test_table, _test_table_headers, tablefmt="heavy_grid")
+    assert_equal(expected, result)
+
+
+def test_heavy_grid_wide_characters():
+    "Output: heavy_grid with wide characters in headers"
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_heavy_grid_wide_characters is skipped")
+    headers = list(_test_table_headers)
+    headers[1] = "配列"
+    expected = "\n".join(
+        [
+            "┏━━━━━━━━━━━┳━━━━━━━━━━┓",
+            "┃ strings   ┃     配列 ┃",
+            "┣━━━━━━━━━━━╋━━━━━━━━━━┫",
+            "┃ spam      ┃  41.9999 ┃",
+            "┣━━━━━━━━━━━╋━━━━━━━━━━┫",
+            "┃ eggs      ┃ 451      ┃",
+            "┗━━━━━━━━━━━┻━━━━━━━━━━┛",
+        ]
+    )
+    result = tabulate(_test_table, headers, tablefmt="heavy_grid")
+    assert_equal(expected, result)
+
+
+def test_heavy_grid_headerless():
+    "Output: heavy_grid without headers"
+    expected = "\n".join(
+        [
+            "┏━━━━━━┳━━━━━━━━━━┓",
+            "┃ spam ┃  41.9999 ┃",
+            "┣━━━━━━╋━━━━━━━━━━┫",
+            "┃ eggs ┃ 451      ┃",
+            "┗━━━━━━┻━━━━━━━━━━┛",
+        ]
+    )
+    result = tabulate(_test_table, tablefmt="heavy_grid")
+    assert_equal(expected, result)
+
+
+def test_heavy_grid_multiline_headerless():
+    "Output: heavy_grid with multiline cells without headers"
+    table = [["foo bar\nbaz\nbau", "hello"], ["", "multiline\nworld"]]
+    expected = "\n".join(
+        [
+            "┏━━━━━━━━━┳━━━━━━━━━━━┓",
+            "┃ foo bar ┃   hello   ┃",
+            "┃   baz   ┃           ┃",
+            "┃   bau   ┃           ┃",
+            "┣━━━━━━━━━╋━━━━━━━━━━━┫",
+            "┃         ┃ multiline ┃",
+            "┃         ┃   world   ┃",
+            "┗━━━━━━━━━┻━━━━━━━━━━━┛",
+        ]
+    )
+    result = tabulate(table, stralign="center", tablefmt="heavy_grid")
+    assert_equal(expected, result)
+
+
+def test_heavy_grid_multiline():
+    "Output: heavy_grid with multiline cells with headers"
+    table = [[2, "foo\nbar"]]
+    headers = ("more\nspam \x1b[31meggs\x1b[0m", "more spam\n& eggs")
+    expected = "\n".join(
+        [
+            "┏━━━━━━━━━━━━━┳━━━━━━━━━━━━━┓",
+            "┃        more ┃ more spam   ┃",
+            "┃   spam \x1b[31meggs\x1b[0m ┃ & eggs      ┃",
+            "┣━━━━━━━━━━━━━╋━━━━━━━━━━━━━┫",
+            "┃           2 ┃ foo         ┃",
+            "┃             ┃ bar         ┃",
+            "┗━━━━━━━━━━━━━┻━━━━━━━━━━━━━┛",
+        ]
+    )
+    result = tabulate(table, headers, tablefmt="heavy_grid")
+    assert_equal(expected, result)
+
+
+def test_heavy_grid_multiline_with_empty_cells():
+    "Output: heavy_grid with multiline cells and empty cells with headers"
+    table = [
+        ["hdr", "data", "fold"],
+        ["1", "", ""],
+        ["2", "very long data", "fold\nthis"],
+    ]
+    expected = "\n".join(
+        [
+            "┏━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━┓",
+            "┃   hdr ┃ data           ┃ fold   ┃",
+            "┣━━━━━━━╋━━━━━━━━━━━━━━━━╋━━━━━━━━┫",
+            "┃     1 ┃                ┃        ┃",
+            "┣━━━━━━━╋━━━━━━━━━━━━━━━━╋━━━━━━━━┫",
+            "┃     2 ┃ very long data ┃ fold   ┃",
+            "┃       ┃                ┃ this   ┃",
+            "┗━━━━━━━┻━━━━━━━━━━━━━━━━┻━━━━━━━━┛",
+        ]
+    )
+    result = tabulate(table, headers="firstrow", tablefmt="heavy_grid")
+    assert_equal(expected, result)
+
+
+def test_heavy_grid_multiline_with_empty_cells_headerless():
+    "Output: heavy_grid with multiline cells and empty cells without headers"
+    table = [["0", "", ""], ["1", "", ""], ["2", "very long data", "fold\nthis"]]
+    expected = "\n".join(
+        [
+            "┏━━━┳━━━━━━━━━━━━━━━━┳━━━━━━┓",
+            "┃ 0 ┃                ┃      ┃",
+            "┣━━━╋━━━━━━━━━━━━━━━━╋━━━━━━┫",
+            "┃ 1 ┃                ┃      ┃",
+            "┣━━━╋━━━━━━━━━━━━━━━━╋━━━━━━┫",
+            "┃ 2 ┃ very long data ┃ fold ┃",
+            "┃   ┃                ┃ this ┃",
+            "┗━━━┻━━━━━━━━━━━━━━━━┻━━━━━━┛",
+        ]
+    )
+    result = tabulate(table, tablefmt="heavy_grid")
+    assert_equal(expected, result)
+
+
+def test_mixed_grid():
+    "Output: mixed_grid with headers"
+    expected = "\n".join(
+        [
+            "┍━━━━━━━━━━━┯━━━━━━━━━━━┑",
+            "│ strings   │   numbers │",
+            "┝━━━━━━━━━━━┿━━━━━━━━━━━┥",
+            "│ spam      │   41.9999 │",
+            "├───────────┼───────────┤",
+            "│ eggs      │  451      │",
+            "┕━━━━━━━━━━━┷━━━━━━━━━━━┙",
+        ]
+    )
+    result = tabulate(_test_table, _test_table_headers, tablefmt="mixed_grid")
+    assert_equal(expected, result)
+
+
+def test_mixed_grid_wide_characters():
+    "Output: mixed_grid with wide characters in headers"
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_mixed_grid_wide_characters is skipped")
+    headers = list(_test_table_headers)
+    headers[1] = "配列"
+    expected = "\n".join(
+        [
+            "┍━━━━━━━━━━━┯━━━━━━━━━━┑",
+            "│ strings   │     配列 │",
+            "┝━━━━━━━━━━━┿━━━━━━━━━━┥",
+            "│ spam      │  41.9999 │",
+            "├───────────┼──────────┤",
+            "│ eggs      │ 451      │",
+            "┕━━━━━━━━━━━┷━━━━━━━━━━┙",
+        ]
+    )
+    result = tabulate(_test_table, headers, tablefmt="mixed_grid")
+    assert_equal(expected, result)
+
+
+def test_mixed_grid_headerless():
+    "Output: mixed_grid without headers"
+    expected = "\n".join(
+        [
+            "┍━━━━━━┯━━━━━━━━━━┑",
+            "│ spam │  41.9999 │",
+            "├──────┼──────────┤",
+            "│ eggs │ 451      │",
+            "┕━━━━━━┷━━━━━━━━━━┙",
+        ]
+    )
+    result = tabulate(_test_table, tablefmt="mixed_grid")
+    assert_equal(expected, result)
+
+
+def test_mixed_grid_multiline_headerless():
+    "Output: mixed_grid with multiline cells without headers"
+    table = [["foo bar\nbaz\nbau", "hello"], ["", "multiline\nworld"]]
+    expected = "\n".join(
+        [
+            "┍━━━━━━━━━┯━━━━━━━━━━━┑",
+            "│ foo bar │   hello   │",
+            "│   baz   │           │",
+            "│   bau   │           │",
+            "├─────────┼───────────┤",
+            "│         │ multiline │",
+            "│         │   world   │",
+            "┕━━━━━━━━━┷━━━━━━━━━━━┙",
+        ]
+    )
+    result = tabulate(table, stralign="center", tablefmt="mixed_grid")
+    assert_equal(expected, result)
+
+
+def test_mixed_grid_multiline():
+    "Output: mixed_grid with multiline cells with headers"
+    table = [[2, "foo\nbar"]]
+    headers = ("more\nspam \x1b[31meggs\x1b[0m", "more spam\n& eggs")
+    expected = "\n".join(
+        [
+            "┍━━━━━━━━━━━━━┯━━━━━━━━━━━━━┑",
+            "│        more │ more spam   │",
+            "│   spam \x1b[31meggs\x1b[0m │ & eggs      │",
+            "┝━━━━━━━━━━━━━┿━━━━━━━━━━━━━┥",
+            "│           2 │ foo         │",
+            "│             │ bar         │",
+            "┕━━━━━━━━━━━━━┷━━━━━━━━━━━━━┙",
+        ]
+    )
+    result = tabulate(table, headers, tablefmt="mixed_grid")
+    assert_equal(expected, result)
+
+
+def test_mixed_grid_multiline_with_empty_cells():
+    "Output: mixed_grid with multiline cells and empty cells with headers"
+    table = [
+        ["hdr", "data", "fold"],
+        ["1", "", ""],
+        ["2", "very long data", "fold\nthis"],
+    ]
+    expected = "\n".join(
+        [
+            "┍━━━━━━━┯━━━━━━━━━━━━━━━━┯━━━━━━━━┑",
+            "│   hdr │ data           │ fold   │",
+            "┝━━━━━━━┿━━━━━━━━━━━━━━━━┿━━━━━━━━┥",
+            "│     1 │                │        │",
+            "├───────┼────────────────┼────────┤",
+            "│     2 │ very long data │ fold   │",
+            "│       │                │ this   │",
+            "┕━━━━━━━┷━━━━━━━━━━━━━━━━┷━━━━━━━━┙",
+        ]
+    )
+    result = tabulate(table, headers="firstrow", tablefmt="mixed_grid")
+    assert_equal(expected, result)
+
+
+def test_mixed_grid_multiline_with_empty_cells_headerless():
+    "Output: mixed_grid with multiline cells and empty cells without headers"
+    table = [["0", "", ""], ["1", "", ""], ["2", "very long data", "fold\nthis"]]
+    expected = "\n".join(
+        [
+            "┍━━━┯━━━━━━━━━━━━━━━━┯━━━━━━┑",
+            "│ 0 │                │      │",
+            "├───┼────────────────┼──────┤",
+            "│ 1 │                │      │",
+            "├───┼────────────────┼──────┤",
+            "│ 2 │ very long data │ fold │",
+            "│   │                │ this │",
+            "┕━━━┷━━━━━━━━━━━━━━━━┷━━━━━━┙",
+        ]
+    )
+    result = tabulate(table, tablefmt="mixed_grid")
+    assert_equal(expected, result)
+
+
+def test_double_grid():
+    "Output: double_grid with headers"
+    expected = "\n".join(
+        [
+            "╔═══════════╦═══════════╗",
+            "║ strings   ║   numbers ║",
+            "╠═══════════╬═══════════╣",
+            "║ spam      ║   41.9999 ║",
+            "╠═══════════╬═══════════╣",
+            "║ eggs      ║  451      ║",
+            "╚═══════════╩═══════════╝",
+        ]
+    )
+    result = tabulate(_test_table, _test_table_headers, tablefmt="double_grid")
+    assert_equal(expected, result)
+
+
+def test_double_grid_wide_characters():
+    "Output: double_grid with wide characters in headers"
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_double_grid_wide_characters is skipped")
+    headers = list(_test_table_headers)
+    headers[1] = "配列"
+    expected = "\n".join(
+        [
+            "╔═══════════╦══════════╗",
+            "║ strings   ║     配列 ║",
+            "╠═══════════╬══════════╣",
+            "║ spam      ║  41.9999 ║",
+            "╠═══════════╬══════════╣",
+            "║ eggs      ║ 451      ║",
+            "╚═══════════╩══════════╝",
+        ]
+    )
+    result = tabulate(_test_table, headers, tablefmt="double_grid")
+    assert_equal(expected, result)
+
+
+def test_double_grid_headerless():
+    "Output: double_grid without headers"
+    expected = "\n".join(
+        [
+            "╔══════╦══════════╗",
+            "║ spam ║  41.9999 ║",
+            "╠══════╬══════════╣",
+            "║ eggs ║ 451      ║",
+            "╚══════╩══════════╝",
+        ]
+    )
+    result = tabulate(_test_table, tablefmt="double_grid")
+    assert_equal(expected, result)
+
+
+def test_double_grid_multiline_headerless():
+    "Output: double_grid with multiline cells without headers"
+    table = [["foo bar\nbaz\nbau", "hello"], ["", "multiline\nworld"]]
+    expected = "\n".join(
+        [
+            "╔═════════╦═══════════╗",
+            "║ foo bar ║   hello   ║",
+            "║   baz   ║           ║",
+            "║   bau   ║           ║",
+            "╠═════════╬═══════════╣",
+            "║         ║ multiline ║",
+            "║         ║   world   ║",
+            "╚═════════╩═══════════╝",
+        ]
+    )
+    result = tabulate(table, stralign="center", tablefmt="double_grid")
+    assert_equal(expected, result)
+
+
+def test_double_grid_multiline():
+    "Output: double_grid with multiline cells with headers"
+    table = [[2, "foo\nbar"]]
+    headers = ("more\nspam \x1b[31meggs\x1b[0m", "more spam\n& eggs")
+    expected = "\n".join(
+        [
+            "╔═════════════╦═════════════╗",
+            "║        more ║ more spam   ║",
+            "║   spam \x1b[31meggs\x1b[0m ║ & eggs      ║",
+            "╠═════════════╬═════════════╣",
+            "║           2 ║ foo         ║",
+            "║             ║ bar         ║",
+            "╚═════════════╩═════════════╝",
+        ]
+    )
+    result = tabulate(table, headers, tablefmt="double_grid")
+    assert_equal(expected, result)
+
+
+def test_double_grid_multiline_with_empty_cells():
+    "Output: double_grid with multiline cells and empty cells with headers"
+    table = [
+        ["hdr", "data", "fold"],
+        ["1", "", ""],
+        ["2", "very long data", "fold\nthis"],
+    ]
+    expected = "\n".join(
+        [
+            "╔═══════╦════════════════╦════════╗",
+            "║   hdr ║ data           ║ fold   ║",
+            "╠═══════╬════════════════╬════════╣",
+            "║     1 ║                ║        ║",
+            "╠═══════╬════════════════╬════════╣",
+            "║     2 ║ very long data ║ fold   ║",
+            "║       ║                ║ this   ║",
+            "╚═══════╩════════════════╩════════╝",
+        ]
+    )
+    result = tabulate(table, headers="firstrow", tablefmt="double_grid")
+    assert_equal(expected, result)
+
+
+def test_double_grid_multiline_with_empty_cells_headerless():
+    "Output: double_grid with multiline cells and empty cells without headers"
+    table = [["0", "", ""], ["1", "", ""], ["2", "very long data", "fold\nthis"]]
+    expected = "\n".join(
+        [
+            "╔═══╦════════════════╦══════╗",
+            "║ 0 ║                ║      ║",
+            "╠═══╬════════════════╬══════╣",
+            "║ 1 ║                ║      ║",
+            "╠═══╬════════════════╬══════╣",
+            "║ 2 ║ very long data ║ fold ║",
+            "║   ║                ║ this ║",
+            "╚═══╩════════════════╩══════╝",
+        ]
+    )
+    result = tabulate(table, tablefmt="double_grid")
+    assert_equal(expected, result)
+
+
+def test_fancy_grid():
+    "Output: fancy_grid with headers"
+    expected = "\n".join(
+        [
+            "╒═══════════╤═══════════╕",
+            "│ strings   │   numbers │",
+            "╞═══════════╪═══════════╡",
+            "│ spam      │   41.9999 │",
+            "├───────────┼───────────┤",
+            "│ eggs      │  451      │",
+            "╘═══════════╧═══════════╛",
+        ]
+    )
+    result = tabulate(_test_table, _test_table_headers, tablefmt="fancy_grid")
+    assert_equal(expected, result)
+
+
+def test_fancy_grid_wide_characters():
+    "Output: fancy_grid with wide characters in headers"
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_fancy_grid_wide_characters is skipped")
+    headers = list(_test_table_headers)
+    headers[1] = "配列"
+    expected = "\n".join(
+        [
+            "╒═══════════╤══════════╕",
+            "│ strings   │     配列 │",
+            "╞═══════════╪══════════╡",
+            "│ spam      │  41.9999 │",
+            "├───────────┼──────────┤",
+            "│ eggs      │ 451      │",
+            "╘═══════════╧══════════╛",
+        ]
+    )
+    result = tabulate(_test_table, headers, tablefmt="fancy_grid")
+    assert_equal(expected, result)
+
+
+def test_fancy_grid_headerless():
+    "Output: fancy_grid without headers"
+    expected = "\n".join(
+        [
+            "╒══════╤══════════╕",
+            "│ spam │  41.9999 │",
+            "├──────┼──────────┤",
+            "│ eggs │ 451      │",
+            "╘══════╧══════════╛",
+        ]
+    )
+    result = tabulate(_test_table, tablefmt="fancy_grid")
+    assert_equal(expected, result)
+
+
+def test_fancy_grid_multiline_headerless():
+    "Output: fancy_grid with multiline cells without headers"
+    table = [["foo bar\nbaz\nbau", "hello"], ["", "multiline\nworld"]]
+    expected = "\n".join(
+        [
+            "╒═════════╤═══════════╕",
+            "│ foo bar │   hello   │",
+            "│   baz   │           │",
+            "│   bau   │           │",
+            "├─────────┼───────────┤",
+            "│         │ multiline │",
+            "│         │   world   │",
+            "╘═════════╧═══════════╛",
+        ]
+    )
+    result = tabulate(table, stralign="center", tablefmt="fancy_grid")
+    assert_equal(expected, result)
+
+
+def test_fancy_grid_multiline():
+    "Output: fancy_grid with multiline cells with headers"
+    table = [[2, "foo\nbar"]]
+    headers = ("more\nspam \x1b[31meggs\x1b[0m", "more spam\n& eggs")
+    expected = "\n".join(
+        [
+            "╒═════════════╤═════════════╕",
+            "│        more │ more spam   │",
+            "│   spam \x1b[31meggs\x1b[0m │ & eggs      │",
+            "╞═════════════╪═════════════╡",
+            "│           2 │ foo         │",
+            "│             │ bar         │",
+            "╘═════════════╧═════════════╛",
+        ]
+    )
+    result = tabulate(table, headers, tablefmt="fancy_grid")
+    assert_equal(expected, result)
+
+
+def test_fancy_grid_multiline_with_empty_cells():
+    "Output: fancy_grid with multiline cells and empty cells with headers"
+    table = [
+        ["hdr", "data", "fold"],
+        ["1", "", ""],
+        ["2", "very long data", "fold\nthis"],
+    ]
+    expected = "\n".join(
+        [
+            "╒═══════╤════════════════╤════════╕",
+            "│   hdr │ data           │ fold   │",
+            "╞═══════╪════════════════╪════════╡",
+            "│     1 │                │        │",
+            "├───────┼────────────────┼────────┤",
+            "│     2 │ very long data │ fold   │",
+            "│       │                │ this   │",
+            "╘═══════╧════════════════╧════════╛",
+        ]
+    )
+    result = tabulate(table, headers="firstrow", tablefmt="fancy_grid")
+    assert_equal(expected, result)
+
+
+def test_fancy_grid_multiline_with_empty_cells_headerless():
+    "Output: fancy_grid with multiline cells and empty cells without headers"
+    table = [["0", "", ""], ["1", "", ""], ["2", "very long data", "fold\nthis"]]
+    expected = "\n".join(
+        [
+            "╒═══╤════════════════╤══════╕",
+            "│ 0 │                │      │",
+            "├───┼────────────────┼──────┤",
+            "│ 1 │                │      │",
+            "├───┼────────────────┼──────┤",
+            "│ 2 │ very long data │ fold │",
+            "│   │                │ this │",
+            "╘═══╧════════════════╧══════╛",
+        ]
+    )
+    result = tabulate(table, tablefmt="fancy_grid")
+    assert_equal(expected, result)
+
+
+def test_fancy_grid_multiline_row_align():
+    "Output: fancy_grid with multiline cells aligning some text not to top of cell"
+    table = [
+        ["0", "some\ndefault\ntext", "up\ntop"],
+        ["1", "very\nlong\ndata\ncell", "mid\ntest"],
+        ["2", "also\nvery\nlong\ndata\ncell", "fold\nthis"],
+    ]
+    expected = "\n".join(
+        [
+            "╒═══╤═════════╤══════╕",
+            "│ 0 │ some    │ up   │",
+            "│   │ default │ top  │",
+            "│   │ text    │      │",
+            "├───┼─────────┼──────┤",
+            "│   │ very    │      │",
+            "│ 1 │ long    │ mid  │",
+            "│   │ data    │ test │",
+            "│   │ cell    │      │",
+            "├───┼─────────┼──────┤",
+            "│   │ also    │      │",
+            "│   │ very    │      │",
+            "│   │ long    │      │",
+            "│   │ data    │ fold │",
+            "│ 2 │ cell    │ this │",
+            "╘═══╧═════════╧══════╛",
+        ]
+    )
+    result = tabulate(table, tablefmt="fancy_grid", rowalign=[None, "center", "bottom"])
+    assert_equal(expected, result)
+
+
+def test_colon_grid():
+    "Output: colon_grid with two columns aligned left and center"
+    expected = "\n".join(
+        [
+            "+------+------+",
+            "| H1   | H2   |",
+            "+=====:+:====:+",
+            "| 3    | 4    |",
+            "+------+------+",
+        ]
+    )
+    result = tabulate([[3, 4]], headers=("H1", "H2"), tablefmt="colon_grid", colalign=["right", "center"])
+    assert_equal(expected, result)
+
+
+def test_colon_grid_wide_characters():
+    "Output: colon_grid with wide chars in header"
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_colon_grid_wide_characters is skipped")
+    headers = list(_test_table_headers)
+    headers[1] = "配列"
+    expected = "\n".join(
+        [
+            "+-----------+---------+",
+            "| strings   | 配列    |",
+            "+:==========+========:+",
+            "| spam      | 41.9999 |",
+            "+-----------+---------+",
+            "| eggs      | 451     |",
+            "+-----------+---------+",
+        ]
+    )
+    result = tabulate(_test_table, headers, tablefmt="colon_grid", colalign=["left", "right"])
+    assert_equal(expected, result)
+
+
+def test_colon_grid_headerless():
+    "Output: colon_grid without headers"
+    expected = "\n".join(
+        [
+            "+------+---------+",
+            "| spam | 41.9999 |",
+            "+------+---------+",
+            "| eggs | 451     |",
+            "+------+---------+",
+        ]
+    )
+    result = tabulate(_test_table, tablefmt="colon_grid")
+    assert_equal(expected, result)
+
+
+def test_colon_grid_multiline():
+    "Output: colon_grid with multiline cells"
+    table = [["Data\n5", "33\n3"]]
+    headers = ["H1\n1", "H2\n2"]
+    expected = "\n".join(
+        [
+            "+------+------+",
+            "| H1   | H2   |",
+            "| 1    | 2    |",
+            "+:=====+:=====+",
+            "| Data | 33   |",
+            "| 5    | 3    |",
+            "+------+------+",
+        ]
+    )
+    result = tabulate(table, headers, tablefmt="colon_grid")
+    assert_equal(expected, result)
+
+
+def test_colon_grid_with_empty_cells():
+    table = [["A", ""], ["", "B"]]
+    headers = ["H1", "H2"]
+    alignments = ["center", "right"]
+    expected = "\n".join(
+        [
+            "+------+------+",
+            "| H1   | H2   |",
+            "+:====:+=====:+",
+            "| A    |      |",
+            "+------+------+",
+            "|      | B    |",
+            "+------+------+",
+        ]
+    )
+    result = tabulate(table, headers, tablefmt="colon_grid", colalign=alignments)
+    assert_equal(expected, result)
+
+
+def test_outline():
+    "Output: outline with headers"
+    expected = "\n".join(
+        [
+            "+-----------+-----------+",
+            "| strings   |   numbers |",
+            "+===========+===========+",
+            "| spam      |   41.9999 |",
+            "| eggs      |  451      |",
+            "+-----------+-----------+",
+        ]
+    )
+    result = tabulate(_test_table, _test_table_headers, tablefmt="outline")
+    assert_equal(expected, result)
+
+
+def test_outline_wide_characters():
+    "Output: outline with wide characters in headers"
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_outline_wide_characters is skipped")
+    headers = list(_test_table_headers)
+    headers[1] = "配列"
+    expected = "\n".join(
+        [
+            "+-----------+----------+",
+            "| strings   |     配列 |",
+            "+===========+==========+",
+            "| spam      |  41.9999 |",
+            "| eggs      | 451      |",
+            "+-----------+----------+",
+        ]
+    )
+    result = tabulate(_test_table, headers, tablefmt="outline")
+    assert_equal(expected, result)
+
+
+def test_outline_headerless():
+    "Output: outline without headers"
+    expected = "\n".join(
+        [
+            "+------+----------+",
+            "| spam |  41.9999 |",
+            "| eggs | 451      |",
+            "+------+----------+",
+        ]
+    )
+    result = tabulate(_test_table, tablefmt="outline")
+    assert_equal(expected, result)
+
+
+def test_simple_outline():
+    "Output: simple_outline with headers"
+    expected = "\n".join(
+        [
+            "┌───────────┬───────────┐",
+            "│ strings   │   numbers │",
+            "├───────────┼───────────┤",
+            "│ spam      │   41.9999 │",
+            "│ eggs      │  451      │",
+            "└───────────┴───────────┘",
+        ]
+    )
+    result = tabulate(_test_table, _test_table_headers, tablefmt="simple_outline")
+    assert_equal(expected, result)
+
+
+def test_simple_outline_wide_characters():
+    "Output: simple_outline with wide characters in headers"
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_simple_outline_wide_characters is skipped")
+    headers = list(_test_table_headers)
+    headers[1] = "配列"
+    expected = "\n".join(
+        [
+            "┌───────────┬──────────┐",
+            "│ strings   │     配列 │",
+            "├───────────┼──────────┤",
+            "│ spam      │  41.9999 │",
+            "│ eggs      │ 451      │",
+            "└───────────┴──────────┘",
+        ]
+    )
+    result = tabulate(_test_table, headers, tablefmt="simple_outline")
+    assert_equal(expected, result)
+
+
+def test_simple_outline_headerless():
+    "Output: simple_outline without headers"
+    expected = "\n".join(
+        [
+            "┌──────┬──────────┐",
+            "│ spam │  41.9999 │",
+            "│ eggs │ 451      │",
+            "└──────┴──────────┘",
+        ]
+    )
+    result = tabulate(_test_table, tablefmt="simple_outline")
+    assert_equal(expected, result)
+
+
+def test_rounded_outline():
+    "Output: rounded_outline with headers"
+    expected = "\n".join(
+        [
+            "╭───────────┬───────────╮",
+            "│ strings   │   numbers │",
+            "├───────────┼───────────┤",
+            "│ spam      │   41.9999 │",
+            "│ eggs      │  451      │",
+            "╰───────────┴───────────╯",
+        ]
+    )
+    result = tabulate(_test_table, _test_table_headers, tablefmt="rounded_outline")
+    assert_equal(expected, result)
+
+
+def test_rounded_outline_wide_characters():
+    "Output: rounded_outline with wide characters in headers"
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_rounded_outline_wide_characters is skipped")
+    headers = list(_test_table_headers)
+    headers[1] = "配列"
+    expected = "\n".join(
+        [
+            "╭───────────┬──────────╮",
+            "│ strings   │     配列 │",
+            "├───────────┼──────────┤",
+            "│ spam      │  41.9999 │",
+            "│ eggs      │ 451      │",
+            "╰───────────┴──────────╯",
+        ]
+    )
+    result = tabulate(_test_table, headers, tablefmt="rounded_outline")
+    assert_equal(expected, result)
+
+
+def test_rounded_outline_headerless():
+    "Output: rounded_outline without headers"
+    expected = "\n".join(
+        [
+            "╭──────┬──────────╮",
+            "│ spam │  41.9999 │",
+            "│ eggs │ 451      │",
+            "╰──────┴──────────╯",
+        ]
+    )
+    result = tabulate(_test_table, tablefmt="rounded_outline")
+    assert_equal(expected, result)
+
+
+def test_heavy_outline():
+    "Output: heavy_outline with headers"
+    expected = "\n".join(
+        [
+            "┏━━━━━━━━━━━┳━━━━━━━━━━━┓",
+            "┃ strings   ┃   numbers ┃",
+            "┣━━━━━━━━━━━╋━━━━━━━━━━━┫",
+            "┃ spam      ┃   41.9999 ┃",
+            "┃ eggs      ┃  451      ┃",
+            "┗━━━━━━━━━━━┻━━━━━━━━━━━┛",
+        ]
+    )
+    result = tabulate(_test_table, _test_table_headers, tablefmt="heavy_outline")
     assert_equal(expected, result)
 
 
-def test_grid_headerless():
-    "Output: grid without headers"
+def test_heavy_outline_wide_characters():
+    "Output: heavy_outline with wide characters in headers"
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_heavy_outline_wide_characters is skipped")
+    headers = list(_test_table_headers)
+    headers[1] = "配列"
     expected = "\n".join(
         [
-            "+------+----------+",
-            "| spam |  41.9999 |",
-            "+------+----------+",
-            "| eggs | 451      |",
-            "+------+----------+",
+            "┏━━━━━━━━━━━┳━━━━━━━━━━┓",
+            "┃ strings   ┃     配列 ┃",
+            "┣━━━━━━━━━━━╋━━━━━━━━━━┫",
+            "┃ spam      ┃  41.9999 ┃",
+            "┃ eggs      ┃ 451      ┃",
+            "┗━━━━━━━━━━━┻━━━━━━━━━━┛",
         ]
     )
-    result = tabulate(_test_table, tablefmt="grid")
+    result = tabulate(_test_table, headers, tablefmt="heavy_outline")
     assert_equal(expected, result)
 
 
-def test_grid_multiline_headerless():
-    "Output: grid with multiline cells without headers"
-    table = [["foo bar\nbaz\nbau", "hello"], ["", "multiline\nworld"]]
+def test_heavy_outline_headerless():
+    "Output: heavy_outline without headers"
     expected = "\n".join(
         [
-            "+---------+-----------+",
-            "| foo bar |   hello   |",
-            "|   baz   |           |",
-            "|   bau   |           |",
-            "+---------+-----------+",
-            "|         | multiline |",
-            "|         |   world   |",
-            "+---------+-----------+",
+            "┏━━━━━━┳━━━━━━━━━━┓",
+            "┃ spam ┃  41.9999 ┃",
+            "┃ eggs ┃ 451      ┃",
+            "┗━━━━━━┻━━━━━━━━━━┛",
         ]
     )
-    result = tabulate(table, stralign="center", tablefmt="grid")
+    result = tabulate(_test_table, tablefmt="heavy_outline")
     assert_equal(expected, result)
 
 
-def test_grid_multiline():
-    "Output: grid with multiline cells with headers"
-    table = [[2, "foo\nbar"]]
-    headers = ("more\nspam \x1b[31meggs\x1b[0m", "more spam\n& eggs")
+def test_mixed_outline():
+    "Output: mixed_outline with headers"
     expected = "\n".join(
         [
-            "+-------------+-------------+",
-            "|        more | more spam   |",
-            "|   spam \x1b[31meggs\x1b[0m | & eggs      |",
-            "+=============+=============+",
-            "|           2 | foo         |",
-            "|             | bar         |",
-            "+-------------+-------------+",
+            "┍━━━━━━━━━━━┯━━━━━━━━━━━┑",
+            "│ strings   │   numbers │",
+            "┝━━━━━━━━━━━┿━━━━━━━━━━━┥",
+            "│ spam      │   41.9999 │",
+            "│ eggs      │  451      │",
+            "┕━━━━━━━━━━━┷━━━━━━━━━━━┙",
         ]
     )
-    result = tabulate(table, headers, tablefmt="grid")
+    result = tabulate(_test_table, _test_table_headers, tablefmt="mixed_outline")
     assert_equal(expected, result)
 
 
-def test_grid_multiline_with_empty_cells():
-    "Output: grid with multiline cells and empty cells with headers"
-    table = [
-        ["hdr", "data", "fold"],
-        ["1", "", ""],
-        ["2", "very long data", "fold\nthis"],
-    ]
+def test_mixed_outline_wide_characters():
+    "Output: mixed_outline with wide characters in headers"
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_mixed_outline_wide_characters is skipped")
+    headers = list(_test_table_headers)
+    headers[1] = "配列"
     expected = "\n".join(
         [
-            "+-------+----------------+--------+",
-            "|   hdr | data           | fold   |",
-            "+=======+================+========+",
-            "|     1 |                |        |",
-            "+-------+----------------+--------+",
-            "|     2 | very long data | fold   |",
-            "|       |                | this   |",
-            "+-------+----------------+--------+",
+            "┍━━━━━━━━━━━┯━━━━━━━━━━┑",
+            "│ strings   │     配列 │",
+            "┝━━━━━━━━━━━┿━━━━━━━━━━┥",
+            "│ spam      │  41.9999 │",
+            "│ eggs      │ 451      │",
+            "┕━━━━━━━━━━━┷━━━━━━━━━━┙",
         ]
     )
-    result = tabulate(table, headers="firstrow", tablefmt="grid")
+    result = tabulate(_test_table, headers, tablefmt="mixed_outline")
     assert_equal(expected, result)
 
 
-def test_grid_multiline_with_empty_cells_headerless():
-    "Output: grid with multiline cells and empty cells without headers"
-    table = [["0", "", ""], ["1", "", ""], ["2", "very long data", "fold\nthis"]]
+def test_mixed_outline_headerless():
+    "Output: mixed_outline without headers"
     expected = "\n".join(
         [
-            "+---+----------------+------+",
-            "| 0 |                |      |",
-            "+---+----------------+------+",
-            "| 1 |                |      |",
-            "+---+----------------+------+",
-            "| 2 | very long data | fold |",
-            "|   |                | this |",
-            "+---+----------------+------+",
+            "┍━━━━━━┯━━━━━━━━━━┑",
+            "│ spam │  41.9999 │",
+            "│ eggs │ 451      │",
+            "┕━━━━━━┷━━━━━━━━━━┙",
         ]
     )
-    result = tabulate(table, tablefmt="grid")
+    result = tabulate(_test_table, tablefmt="mixed_outline")
     assert_equal(expected, result)
 
 
-def test_fancy_grid():
-    "Output: fancy_grid with headers"
+def test_double_outline():
+    "Output: double_outline with headers"
     expected = "\n".join(
         [
-            "╒═══════════╤═══════════╕",
-            "│ strings   │   numbers │",
-            "╞═══════════╪═══════════╡",
-            "│ spam      │   41.9999 │",
-            "├───────────┼───────────┤",
-            "│ eggs      │  451      │",
-            "╘═══════════╧═══════════╛",
+            "╔═══════════╦═══════════╗",
+            "║ strings   ║   numbers ║",
+            "╠═══════════╬═══════════╣",
+            "║ spam      ║   41.9999 ║",
+            "║ eggs      ║  451      ║",
+            "╚═══════════╩═══════════╝",
         ]
     )
-    result = tabulate(_test_table, _test_table_headers, tablefmt="fancy_grid")
+    result = tabulate(_test_table, _test_table_headers, tablefmt="double_outline")
     assert_equal(expected, result)
 
 
-def test_fancy_grid_headerless():
-    "Output: fancy_grid without headers"
+def test_double_outline_wide_characters():
+    "Output: double_outline with wide characters in headers"
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_double_outline_wide_characters is skipped")
+    headers = list(_test_table_headers)
+    headers[1] = "配列"
     expected = "\n".join(
         [
-            "╒══════╤══════════╕",
-            "│ spam │  41.9999 │",
-            "├──────┼──────────┤",
-            "│ eggs │ 451      │",
-            "╘══════╧══════════╛",
+            "╔═══════════╦══════════╗",
+            "║ strings   ║     配列 ║",
+            "╠═══════════╬══════════╣",
+            "║ spam      ║  41.9999 ║",
+            "║ eggs      ║ 451      ║",
+            "╚═══════════╩══════════╝",
         ]
     )
-    result = tabulate(_test_table, tablefmt="fancy_grid")
+    result = tabulate(_test_table, headers, tablefmt="double_outline")
     assert_equal(expected, result)
 
 
-def test_fancy_grid_multiline_headerless():
-    "Output: fancy_grid with multiline cells without headers"
-    table = [["foo bar\nbaz\nbau", "hello"], ["", "multiline\nworld"]]
+def test_double_outline_headerless():
+    "Output: double_outline without headers"
     expected = "\n".join(
         [
-            "╒═════════╤═══════════╕",
-            "│ foo bar │   hello   │",
-            "│   baz   │           │",
-            "│   bau   │           │",
-            "├─────────┼───────────┤",
-            "│         │ multiline │",
-            "│         │   world   │",
-            "╘═════════╧═══════════╛",
+            "╔══════╦══════════╗",
+            "║ spam ║  41.9999 ║",
+            "║ eggs ║ 451      ║",
+            "╚══════╩══════════╝",
         ]
     )
-    result = tabulate(table, stralign="center", tablefmt="fancy_grid")
+    result = tabulate(_test_table, tablefmt="double_outline")
     assert_equal(expected, result)
 
 
-def test_fancy_grid_multiline():
-    "Output: fancy_grid with multiline cells with headers"
-    table = [[2, "foo\nbar"]]
-    headers = ("more\nspam \x1b[31meggs\x1b[0m", "more spam\n& eggs")
+def test_fancy_outline():
+    "Output: fancy_outline with headers"
     expected = "\n".join(
         [
-            "╒═════════════╤═════════════╕",
-            "│        more │ more spam   │",
-            "│   spam \x1b[31meggs\x1b[0m │ & eggs      │",
-            "╞═════════════╪═════════════╡",
-            "│           2 │ foo         │",
-            "│             │ bar         │",
-            "╘═════════════╧═════════════╛",
+            "╒═══════════╤═══════════╕",
+            "│ strings   │   numbers │",
+            "╞═══════════╪═══════════╡",
+            "│ spam      │   41.9999 │",
+            "│ eggs      │  451      │",
+            "╘═══════════╧═══════════╛",
         ]
     )
-    result = tabulate(table, headers, tablefmt="fancy_grid")
+    result = tabulate(_test_table, _test_table_headers, tablefmt="fancy_outline")
     assert_equal(expected, result)
 
 
-def test_fancy_grid_multiline_with_empty_cells():
-    "Output: fancy_grid with multiline cells and empty cells with headers"
-    table = [
-        ["hdr", "data", "fold"],
-        ["1", "", ""],
-        ["2", "very long data", "fold\nthis"],
-    ]
+def test_fancy_outline_wide_characters():
+    "Output: fancy_outline with wide characters in headers"
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_fancy_outline_wide_characters is skipped")
+    headers = list(_test_table_headers)
+    headers[1] = "配列"
     expected = "\n".join(
         [
-            "╒═══════╤════════════════╤════════╕",
-            "│   hdr │ data           │ fold   │",
-            "╞═══════╪════════════════╪════════╡",
-            "│     1 │                │        │",
-            "├───────┼────────────────┼────────┤",
-            "│     2 │ very long data │ fold   │",
-            "│       │                │ this   │",
-            "╘═══════╧════════════════╧════════╛",
+            "╒═══════════╤══════════╕",
+            "│ strings   │     配列 │",
+            "╞═══════════╪══════════╡",
+            "│ spam      │  41.9999 │",
+            "│ eggs      │ 451      │",
+            "╘═══════════╧══════════╛",
         ]
     )
-    result = tabulate(table, headers="firstrow", tablefmt="fancy_grid")
+    result = tabulate(_test_table, headers, tablefmt="fancy_outline")
     assert_equal(expected, result)
 
 
-def test_fancy_grid_multiline_with_empty_cells_headerless():
-    "Output: fancy_grid with multiline cells and empty cells without headers"
-    table = [["0", "", ""], ["1", "", ""], ["2", "very long data", "fold\nthis"]]
+def test_fancy_outline_headerless():
+    "Output: fancy_outline without headers"
     expected = "\n".join(
         [
-            "╒═══╤════════════════╤══════╕",
-            "│ 0 │                │      │",
-            "├───┼────────────────┼──────┤",
-            "│ 1 │                │      │",
-            "├───┼────────────────┼──────┤",
-            "│ 2 │ very long data │ fold │",
-            "│   │                │ this │",
-            "╘═══╧════════════════╧══════╛",
+            "╒══════╤══════════╕",
+            "│ spam │  41.9999 │",
+            "│ eggs │ 451      │",
+            "╘══════╧══════════╛",
         ]
     )
-    result = tabulate(table, tablefmt="fancy_grid")
+    result = tabulate(_test_table, tablefmt="fancy_outline")
     assert_equal(expected, result)
 
 
@@ -626,6 +2036,37 @@ def test_orgtbl_headerless():
     assert_equal(expected, result)
 
 
+def test_asciidoc():
+    "Output: asciidoc with headers"
+    expected = "\n".join(
+        [
+            '[cols="11<,11>",options="header"]',
+            "|====",
+            "| strings   |   numbers ",
+            "| spam      |   41.9999 ",
+            "| eggs      |  451      ",
+            "|====",
+        ]
+    )
+    result = tabulate(_test_table, _test_table_headers, tablefmt="asciidoc")
+    assert_equal(expected, result)
+
+
+def test_asciidoc_headerless():
+    "Output: asciidoc without headers"
+    expected = "\n".join(
+        [
+            '[cols="6<,10>"]',
+            "|====",
+            "| spam |  41.9999 ",
+            "| eggs | 451      ",
+            "|====",
+        ]
+    )
+    result = tabulate(_test_table, tablefmt="asciidoc")
+    assert_equal(expected, result)
+
+
 def test_psql():
     "Output: psql with headers"
     expected = "\n".join(
@@ -1006,11 +2447,11 @@ def test_mediawiki():
             '{| class="wikitable" style="text-align: left;"',
             "|+ <!-- caption -->",
             "|-",
-            '! strings   !! align="right"|   numbers',
+            '! strings   !! style="text-align: right;"|   numbers',
             "|-",
-            '| spam      || align="right"|   41.9999',
+            '| spam      || style="text-align: right;"|   41.9999',
             "|-",
-            '| eggs      || align="right"|  451',
+            '| eggs      || style="text-align: right;"|  451',
             "|}",
         ]
     )
@@ -1025,9 +2466,9 @@ def test_mediawiki_headerless():
             '{| class="wikitable" style="text-align: left;"',
             "|+ <!-- caption -->",
             "|-",
-            '| spam || align="right"|  41.9999',
+            '| spam || style="text-align: right;"|  41.9999',
             "|-",
-            '| eggs || align="right"| 451',
+            '| eggs || style="text-align: right;"| 451',
             "|}",
         ]
     )
@@ -1315,6 +2756,58 @@ def test_empty_data_without_headers():
     assert_equal(expected, result)
 
 
+def test_intfmt():
+    "Output: integer format"
+    result = tabulate([[10000], [10]], intfmt=",", tablefmt="plain")
+    expected = "10,000\n    10"
+    assert_equal(expected, result)
+
+
+def test_intfmt_with_string_as_integer():
+    "Output: integer format"
+    result = tabulate([[82642], ["1500"], [2463]], intfmt=",", tablefmt="plain")
+    expected = "82,642\n  1500\n 2,463"
+    assert_equal(expected, result)
+
+
+@mark.skip(reason="It detects all values as floats but there are strings and integers.")
+def test_intfmt_with_string_with_floats():
+    "Output: integer format"
+    result = tabulate([[82000.38], ["1500.47"], ["2463"], [92165]], intfmt=",", tablefmt="plain")
+    expected = "82000.4\n 1500.47\n 2463\n92,165"
+    assert_equal(expected, result)
+
+
+def test_intfmt_with_colors():
+    "Regression: Align ANSI-colored values as if they were colorless."
+    colortable = [
+        ("\x1b[33mabc\x1b[0m", 42, "\x1b[31m42\x1b[0m"),
+        ("\x1b[35mdef\x1b[0m", 987654321, "\x1b[32m987654321\x1b[0m"),
+    ]
+    colorheaders = ("test", "\x1b[34mtest\x1b[0m", "test")
+    formatted = tabulate(colortable, colorheaders, "grid", intfmt=",")
+    expected = "\n".join(
+        [
+            "+--------+-------------+-------------+",
+            "| test   |        \x1b[34mtest\x1b[0m |        test |",
+            "+========+=============+=============+",
+            "| \x1b[33mabc\x1b[0m    |          42 |          \x1b[31m42\x1b[0m |",
+            "+--------+-------------+-------------+",
+            "| \x1b[35mdef\x1b[0m    | 987,654,321 | \x1b[32m987,654,321\x1b[0m |",
+            "+--------+-------------+-------------+",
+        ]
+    )
+    print(f"expected: {expected!r}\n\ngot:      {formatted!r}\n")
+    assert_equal(expected, formatted)
+
+
+def test_empty_data_with_headers():
+    "Output: table with empty data and headers as firstrow"
+    expected = ""
+    result = tabulate([], headers="firstrow")
+    assert_equal(expected, result)
+
+
 def test_floatfmt():
     "Output: floating point format"
     result = tabulate([["1.23456789"], [1.0]], floatfmt=".3f", tablefmt="plain")
@@ -1340,6 +2833,83 @@ def test_colalign_multi():
     assert_equal(expected, result)
 
 
+def test_colalign_multi_with_sep_line():
+    "Output: string columns with custom colalign"
+    result = tabulate(
+        [["one", "two"], SEPARATING_LINE, ["three", "four"]],
+        colalign=("right",),
+        tablefmt="plain",
+    )
+    expected = "  one  two\n\nthree  four"
+    assert_equal(expected, result)
+
+
+def test_column_global_and_specific_alignment():
+    """Test `colglobalalign` and `"global"` parameter for `colalign`."""
+    table = [[1, 2, 3, 4], [111, 222, 333, 444]]
+    colglobalalign = "center"
+    colalign = ("global", "left", "right")
+    result = tabulate(table, colglobalalign=colglobalalign, colalign=colalign)
+    expected = "\n".join(
+        [
+            "---  ---  ---  ---",
+            " 1   2      3   4",
+            "111  222  333  444",
+            "---  ---  ---  ---",
+        ]
+    )
+    assert_equal(expected, result)
+
+
+def test_headers_global_and_specific_alignment():
+    """Test `headersglobalalign` and `headersalign`."""
+    table = [[1, 2, 3, 4, 5, 6], [111, 222, 333, 444, 555, 666]]
+    colglobalalign = "center"
+    colalign = ("left",)
+    headers = ["h", "e", "a", "d", "e", "r"]
+    headersglobalalign = "right"
+    headersalign = ("same", "same", "left", "global", "center")
+    result = tabulate(
+        table,
+        headers=headers,
+        colglobalalign=colglobalalign,
+        colalign=colalign,
+        headersglobalalign=headersglobalalign,
+        headersalign=headersalign,
+    )
+    expected = "\n".join(
+        [
+            "h     e   a      d   e     r",
+            "---  ---  ---  ---  ---  ---",
+            "1     2    3    4    5    6",
+            "111  222  333  444  555  666",
+        ]
+    )
+    assert_equal(expected, result)
+
+
+def test_colalign_or_headersalign_too_long():
+    """Test `colalign` and `headersalign` too long."""
+    table = [[1, 2], [111, 222]]
+    colalign = ("global", "left", "center")
+    headers = ["h"]
+    headersalign = ("center", "right", "same")
+    result = tabulate(
+        table, headers=headers, colalign=colalign, headersalign=headersalign
+    )
+    expected = "\n".join(["      h", "---  ---", "  1  2", "111  222"])
+    assert_equal(expected, result)
+
+
+def test_warning_when_colalign_or_headersalign_is_string():
+    """Test user warnings when `colalign` or `headersalign` is a string."""
+    table = [[1, "bar"]]
+    opt = {"colalign": "center", "headers": ["foo", "2"], "headersalign": "center"}
+    check_warnings(
+        (tabulate, [table], opt), num=2, category=UserWarning, contain="As a string"
+    )
+
+
 def test_float_conversions():
     "Output: float format parsed"
     test_headers = ["str", "bad_float", "just_float", "with_inf", "with_nan", "neg_inf"]
@@ -1506,7 +3076,7 @@ def test_dict_like_with_index():
     dd = {"b": range(101, 104)}
     expected = "\n".join(["      b", "--  ---", " 0  101", " 1  102", " 2  103"])
     result = tabulate(dd, "keys", showindex=True)
-    assert_equal(result, expected)
+    assert_equal(expected, result)
 
 
 def test_list_of_lists_with_index():
@@ -1518,7 +3088,47 @@ def test_list_of_lists_with_index():
         ["      a    b", "--  ---  ---", " 0    0  101", " 1    1  102", " 2    2  103"]
     )
     result = tabulate(dd, headers=["a", "b"], showindex=True)
-    assert_equal(result, expected)
+    assert_equal(expected, result)
+
+
+def test_list_of_lists_with_index_with_sep_line():
+    "Output: a table with a running index"
+    dd = [(0, 101), SEPARATING_LINE, (1, 102), (2, 103)]
+    # keys' order (hence columns' order) is not deterministic in Python 3
+    # => we have to consider both possible results as valid
+    expected = "\n".join(
+        [
+            "      a    b",
+            "--  ---  ---",
+            " 0    0  101",
+            "--  ---  ---",
+            " 1    1  102",
+            " 2    2  103",
+        ]
+    )
+    result = tabulate(dd, headers=["a", "b"], showindex=True)
+    assert_equal(expected, result)
+
+
+def test_with_padded_columns_with_sep_line():
+    table = [
+        ["1", "one"],  # "1" as a str on purpose
+        [1_000, "one K"],
+        SEPARATING_LINE,
+        [1_000_000, "one M"],
+    ]
+    expected = "\n".join(
+        [
+            "+---------+-------+",
+            "|       1 | one   |",
+            "|    1000 | one K |",
+            "|---------+-------|",
+            "| 1000000 | one M |",
+            "+---------+-------+",
+        ]
+    )
+    result = tabulate(table, tablefmt="psql")
+    assert_equal(expected, result)
 
 
 def test_list_of_lists_with_supplied_index():
@@ -1528,7 +3138,7 @@ def test_list_of_lists_with_supplied_index():
         ["      a    b", "--  ---  ---", " 1    0  101", " 2    1  102", " 3    2  103"]
     )
     result = tabulate(dd, headers=["a", "b"], showindex=[1, 2, 3])
-    assert_equal(result, expected)
+    assert_equal(expected, result)
     # TODO: make it a separate test case
     # the index must be as long as the number of rows
     with raises(ValueError):
@@ -1542,7 +3152,7 @@ def test_list_of_lists_with_index_firstrow():
         ["      a    b", "--  ---  ---", " 0    0  101", " 1    1  102", " 2    2  103"]
     )
     result = tabulate(dd, headers="firstrow", showindex=True)
-    assert_equal(result, expected)
+    assert_equal(expected, result)
     # TODO: make it a separate test case
     # the index must be as long as the number of rows
     with raises(ValueError):
@@ -1598,18 +3208,16 @@ def test_disable_numparse_list():
 
 def test_preserve_whitespace():
     "Output: Default table output, but with preserved leading whitespace."
-    tabulate_module.PRESERVE_WHITESPACE = True
     table_headers = ["h1", "h2", "h3"]
     test_table = [["  foo", " bar   ", "foo"]]
     expected = "\n".join(
         ["h1     h2       h3", "-----  -------  ----", "  foo   bar     foo"]
     )
-    result = tabulate(test_table, table_headers)
+    result = tabulate(test_table, table_headers, preserve_whitespace=True)
     assert_equal(expected, result)
 
-    tabulate_module.PRESERVE_WHITESPACE = False
     table_headers = ["h1", "h2", "h3"]
     test_table = [["  foo", " bar   ", "foo"]]
     expected = "\n".join(["h1    h2    h3", "----  ----  ----", "foo   bar   foo"])
-    result = tabulate(test_table, table_headers)
+    result = tabulate(test_table, table_headers, preserve_whitespace=False)
     assert_equal(expected, result)
diff --git a/test/test_regression.py b/test/test_regression.py
index 63e3a52..d2fc3a0 100644
--- a/test/test_regression.py
+++ b/test/test_regression.py
@@ -1,11 +1,7 @@
-# -*- coding: utf-8 -*-
-
 """Regression tests."""
 
-from __future__ import print_function
-from __future__ import unicode_literals
-from tabulate import tabulate, _text_type, _long_type, TableFormat, Line, DataRow
-from common import assert_equal, assert_in, skip
+from tabulate import tabulate, TableFormat, Line, DataRow
+from common import assert_equal, skip
 
 
 def test_ansi_color_in_table_cells():
@@ -20,7 +16,7 @@ def test_ansi_color_in_table_cells():
             "| test   | \x1b[31mtest\x1b[0m   | \x1b[32mtest\x1b[0m   |",
         ]
     )
-    print("expected: %r\n\ngot:      %r\n" % (expected, formatted))
+    print(f"expected: {expected!r}\n\ngot:      {formatted!r}\n")
     assert_equal(expected, formatted)
 
 
@@ -43,7 +39,7 @@ def test_alignment_of_colored_cells():
             "+--------+--------+--------+",
         ]
     )
-    print("expected: %r\n\ngot:      %r\n" % (expected, formatted))
+    print(f"expected: {expected!r}\n\ngot:      {formatted!r}\n")
     assert_equal(expected, formatted)
 
 
@@ -66,7 +62,7 @@ def test_alignment_of_link_cells():
             "+--------+--------+--------+",
         ]
     )
-    print("expected: %r\n\ngot:      %r\n" % (expected, formatted))
+    print(f"expected: {expected!r}\n\ngot:      {formatted!r}\n")
     assert_equal(expected, formatted)
 
 
@@ -89,7 +85,7 @@ def test_alignment_of_link_text_cells():
             "+--------+----------+--------+",
         ]
     )
-    print("expected: %r\n\ngot:      %r\n" % (expected, formatted))
+    print(f"expected: {expected!r}\n\ngot:      {formatted!r}\n")
     assert_equal(expected, formatted)
 
 
@@ -98,15 +94,13 @@ def test_iter_of_iters_with_headers():
 
     def mk_iter_of_iters():
         def mk_iter():
-            for i in range(3):
-                yield i
+            yield from range(3)
 
         for r in range(3):
             yield mk_iter()
 
     def mk_headers():
-        for h in ["a", "b", "c"]:
-            yield h
+        yield from ["a", "b", "c"]
 
     formatted = tabulate(mk_iter_of_iters(), headers=mk_headers())
     expected = "\n".join(
@@ -118,7 +112,7 @@ def mk_headers():
             "  0    1    2",
         ]
     )
-    print("expected: %r\n\ngot:      %r\n" % (expected, formatted))
+    print(f"expected: {expected!r}\n\ngot:      {formatted!r}\n")
     assert_equal(expected, formatted)
 
 
@@ -137,7 +131,7 @@ def test_datetime_values():
             "-------------------  ----------  --------",
         ]
     )
-    print("expected: %r\n\ngot:      %r\n" % (expected, formatted))
+    print(f"expected: {expected!r}\n\ngot:      {formatted!r}\n")
     assert_equal(expected, formatted)
 
 
@@ -148,19 +142,10 @@ def test_simple_separated_format():
     fmt = simple_separated_format("!")
     expected = "spam!eggs"
     formatted = tabulate([["spam", "eggs"]], tablefmt=fmt)
-    print("expected: %r\n\ngot:      %r\n" % (expected, formatted))
+    print(f"expected: {expected!r}\n\ngot:      {formatted!r}\n")
     assert_equal(expected, formatted)
 
 
-def py3test_require_py3():
-    "Regression: py33 tests should actually use Python 3 (issue #13)"
-    from platform import python_version_tuple
-
-    print("Expected Python version: 3.x.x")
-    print("Python version used for tests: %s.%s.%s" % python_version_tuple())
-    assert_equal(python_version_tuple()[0], "3")
-
-
 def test_simple_separated_format_with_headers():
     "Regression: simple_separated_format() on tables with headers (issue #15)"
     from tabulate import simple_separated_format
@@ -174,20 +159,20 @@ def test_simple_separated_format_with_headers():
 
 def test_column_type_of_bytestring_columns():
     "Regression: column type for columns of bytestrings (issue #16)"
-    from tabulate import _column_type, _binary_type
+    from tabulate import _column_type
 
     result = _column_type([b"foo", b"bar"])
-    expected = _binary_type
-    assert_equal(result, expected)
+    expected = bytes
+    assert_equal(expected, result)
 
 
 def test_numeric_column_headers():
     "Regression: numbers as column headers (issue #22)"
     result = tabulate([[1], [2]], [42])
     expected = "  42\n----\n   1\n   2"
-    assert_equal(result, expected)
+    assert_equal(expected, result)
 
-    lod = [dict((p, i) for p in range(5)) for i in range(5)]
+    lod = [{p: i for p in range(5)} for i in range(5)]
     result = tabulate(lod, "keys")
     expected = "\n".join(
         [
@@ -200,7 +185,7 @@ def test_numeric_column_headers():
             "  4    4    4    4    4",
         ]
     )
-    assert_equal(result, expected)
+    assert_equal(expected, result)
 
 
 def test_88_256_ANSI_color_codes():
@@ -215,7 +200,7 @@ def test_88_256_ANSI_color_codes():
             "| \x1b[48;5;196mred\x1b[49m          | \x1b[38;5;196mred\x1b[39m          |",
         ]
     )
-    print("expected: %r\n\ngot:      %r\n" % (expected, formatted))
+    print(f"expected: {expected!r}\n\ngot:      {formatted!r}\n")
     assert_equal(expected, formatted)
 
 
@@ -241,15 +226,14 @@ def test_latex_escape_special_chars():
         ]
     )
     result = tabulate([["&%^_$#{}<>~"]], ["foo^bar"], tablefmt="latex")
-    assert_equal(result, expected)
+    assert_equal(expected, result)
 
 
 def test_isconvertible_on_set_values():
     "Regression: don't fail with TypeError on set values (issue #35)"
-    expected_py2 = "\n".join(["a    b", "---  -------", "Foo  set([])"])
-    expected_py3 = "\n".join(["a    b", "---  -----", "Foo  set()"])
+    expected = "\n".join(["a    b", "---  -----", "Foo  set()"])
     result = tabulate([["Foo", set()]], headers=["a", "b"])
-    assert_in(result, [expected_py2, expected_py3])
+    assert_equal(expected, result)
 
 
 def test_ansi_color_for_decimal_numbers():
@@ -259,7 +243,7 @@ def test_ansi_color_for_decimal_numbers():
         ["-------  ---", "Magenta  \x1b[95m1.1\x1b[0m", "-------  ---"]
     )
     result = tabulate(table)
-    assert_equal(result, expected)
+    assert_equal(expected, result)
 
 
 def test_alignment_of_decimal_numbers_with_ansi_color():
@@ -269,7 +253,7 @@ def test_alignment_of_decimal_numbers_with_ansi_color():
     table = [[v1], [v2]]
     expected = "\n".join(["\x1b[95m12.34\x1b[0m", " \x1b[95m1.23456\x1b[0m"])
     result = tabulate(table, tablefmt="plain")
-    assert_equal(result, expected)
+    assert_equal(expected, result)
 
 
 def test_alignment_of_decimal_numbers_with_commas():
@@ -282,7 +266,7 @@ def test_alignment_of_decimal_numbers_with_commas():
     #    '+------+-----------+', '| c1r2 |    105.00 |',
     #    '+------+-----------+']
     # )
-    # assert_equal(result, expected)
+    # assert_equal(expected, result)
 
 
 def test_long_integers():
@@ -290,7 +274,7 @@ def test_long_integers():
     table = [[18446744073709551614]]
     result = tabulate(table, tablefmt="plain")
     expected = "18446744073709551614"
-    assert_equal(result, expected)
+    assert_equal(expected, result)
 
 
 def test_colorclass_colors():
@@ -301,16 +285,16 @@ def test_colorclass_colors():
         s = colorclass.Color("{magenta}3.14{/magenta}")
         result = tabulate([[s]], tablefmt="plain")
         expected = "\x1b[35m3.14\x1b[39m"
-        assert_equal(result, expected)
+        assert_equal(expected, result)
     except ImportError:
 
-        class textclass(_text_type):
+        class textclass(str):
             pass
 
         s = textclass("\x1b[35m3.14\x1b[39m")
         result = tabulate([[s]], tablefmt="plain")
         expected = "\x1b[35m3.14\x1b[39m"
-        assert_equal(result, expected)
+        assert_equal(expected, result)
 
 
 def test_mix_normal_and_wide_characters():
@@ -330,7 +314,7 @@ def test_mix_normal_and_wide_characters():
                 "+--------+",
             ]
         )
-        assert_equal(result, expected)
+        assert_equal(expected, result)
     except ImportError:
         skip("test_mix_normal_and_wide_characters is skipped (requires wcwidth lib)")
 
@@ -350,17 +334,17 @@ def test_multiline_with_wide_characters():
                 "╘══════╧══════╧══════╛",
             ]
         )
-        assert_equal(result, expected)
+        assert_equal(expected, result)
     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)]]
+    table = [[int(1)], [int(234)]]
     result = tabulate(table, tablefmt="plain")
     expected = "\n".join(["  1", "234"])
-    assert_equal(result, expected)
+    assert_equal(expected, result)
 
 
 def test_numpy_array_as_headers():
@@ -371,7 +355,7 @@ def test_numpy_array_as_headers():
         headers = np.array(["foo", "bar"])
         result = tabulate([], headers, tablefmt="plain")
         expected = "foo    bar"
-        assert_equal(result, expected)
+        assert_equal(expected, result)
     except ImportError:
         raise skip("")
 
@@ -381,7 +365,7 @@ def test_boolean_columns():
     xortable = [[False, True], [True, False]]
     expected = "\n".join(["False  True", "True   False"])
     result = tabulate(xortable, tablefmt="plain")
-    assert_equal(result, expected)
+    assert_equal(expected, result)
 
 
 def test_ansi_color_bold_and_fgcolor():
@@ -399,14 +383,14 @@ def test_ansi_color_bold_and_fgcolor():
             "+---+---+---+",
         ]
     )
-    assert_equal(result, expected)
+    assert_equal(expected, result)
 
 
 def test_empty_table_with_keys_as_header():
     "Regression: headers='keys' on an empty table (issue #81)"
     result = tabulate([], headers="keys")
     expected = ""
-    assert_equal(result, expected)
+    assert_equal(expected, result)
 
 
 def test_escape_empty_cell_in_first_column_in_rst():
@@ -425,7 +409,7 @@ def test_escape_empty_cell_in_first_column_in_rst():
         ]
     )
     result = tabulate(table, headers, tablefmt="rst")
-    assert_equal(result, expected)
+    assert_equal(expected, result)
 
 
 def test_ragged_rows():
@@ -433,7 +417,7 @@ def test_ragged_rows():
     table = [[1, 2, 3], [1, 2], [1, 2, 3, 4]]
     expected = "\n".join(["-  -  -  -", "1  2  3", "1  2", "1  2  3  4", "-  -  -  -"])
     result = tabulate(table)
-    assert_equal(result, expected)
+    assert_equal(expected, result)
 
 
 def test_empty_pipe_table_with_columns():
@@ -442,7 +426,7 @@ def test_empty_pipe_table_with_columns():
     headers = ["Col1", "Col2"]
     expected = "\n".join(["| Col1   | Col2   |", "|--------|--------|"])
     result = tabulate(table, headers, tablefmt="pipe")
-    assert_equal(result, expected)
+    assert_equal(expected, result)
 
 
 def test_custom_tablefmt():
@@ -460,7 +444,7 @@ def test_custom_tablefmt():
     rows = [["foo", "bar"], ["baz", "qux"]]
     expected = "\n".join(["A    B", "---  ---", "foo  bar", "baz  qux"])
     result = tabulate(rows, headers=["A", "B"], tablefmt=tablefmt)
-    assert_equal(result, expected)
+    assert_equal(expected, result)
 
 
 def test_string_with_comma_between_digits_without_floatfmt_grouping_option():
@@ -468,4 +452,74 @@ def test_string_with_comma_between_digits_without_floatfmt_grouping_option():
     table = [["126,000"]]
     expected = "126,000"
     result = tabulate(table, tablefmt="plain")
-    assert_equal(result, expected)  # no exception
+    assert_equal(expected, result)  # no exception
+
+
+def test_iterable_row_index():
+    "Regression: accept 'infinite' row indices (github issue #175)"
+    table = [["a"], ["b"], ["c"]]
+
+    def count(start, step=1):
+        n = start
+        while True:
+            yield n
+            n += step
+            if n >= 10:  # safety valve
+                raise IndexError("consuming too many values from the count iterator")
+
+    expected = "1  a\n2  b\n3  c"
+    result = tabulate(table, showindex=count(1), tablefmt="plain")
+    assert_equal(expected, result)
+
+
+def test_preserve_line_breaks_with_maxcolwidths():
+    "Regression: preserve line breaks when using maxcolwidths (github issue #190)"
+    table = [["123456789 bbb\nccc"]]
+    expected = "\n".join(
+        [
+            "+-----------+",
+            "| 123456789 |",
+            "| bbb       |",
+            "| ccc       |",
+            "+-----------+",
+        ]
+    )
+    result = tabulate(table, tablefmt="grid", maxcolwidths=10)
+    assert_equal(expected, result)
+
+
+def test_exception_on_empty_data_with_maxcolwidths():
+    "Regression: exception on empty data when using maxcolwidths (github issue #180)"
+    result = tabulate([], maxcolwidths=5)
+    assert_equal(result, "")
+
+
+def test_numpy_int64_as_integer():
+    "Regression: format numpy.int64 as integer (github issue #18)"
+    try:
+        import numpy as np
+
+        headers = ["int", "float"]
+        table = [[np.int64(1), 3.14159]]
+        result = tabulate(table, headers, tablefmt="pipe", floatfmt=".2f")
+        expected = "\n".join(
+            [
+                "|   int |   float |",
+                "|------:|--------:|",
+                "|     1 |    3.14 |",
+            ]
+        )
+        assert_equal(expected, result)
+    except ImportError:
+        raise skip("")
+
+def test_empty_table_with_colalign():
+    "Regression: empty table with colalign kwarg"
+    table = tabulate([], ["a", "b", "c"], colalign=("center", "left", "left", "center"))
+    expected = "\n".join(
+        [
+            "a    b    c",
+            "---  ---  ---",
+        ]
+    )
+    assert_equal(expected, table)
diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py
new file mode 100644
index 0000000..b315a5c
--- /dev/null
+++ b/test/test_textwrapper.py
@@ -0,0 +1,185 @@
+"""Discretely test functionality of our custom TextWrapper"""
+import datetime
+
+from tabulate import _CustomTextWrap as CTW, tabulate
+from textwrap import TextWrapper as OTW
+
+from common import skip, assert_equal
+
+
+def test_wrap_multiword_non_wide():
+    """TextWrapper: non-wide character regression tests"""
+    data = "this is a test string for regression splitting"
+    for width in range(1, len(data)):
+        orig = OTW(width=width)
+        cust = CTW(width=width)
+
+        assert orig.wrap(data) == cust.wrap(
+            data
+        ), "Failure on non-wide char multiword regression check for width " + str(width)
+
+
+def test_wrap_multiword_non_wide_with_hypens():
+    """TextWrapper: non-wide character regression tests that contain hyphens"""
+    data = "how should-we-split-this non-sense string that-has-lots-of-hypens"
+    for width in range(1, len(data)):
+        orig = OTW(width=width)
+        cust = CTW(width=width)
+
+        assert orig.wrap(data) == cust.wrap(
+            data
+        ), "Failure on non-wide char hyphen regression check for width " + str(width)
+
+
+def test_wrap_longword_non_wide():
+    """TextWrapper: Some non-wide character regression tests"""
+    data = "ThisIsASingleReallyLongWordThatWeNeedToSplit"
+    for width in range(1, len(data)):
+        orig = OTW(width=width)
+        cust = CTW(width=width)
+
+        assert orig.wrap(data) == cust.wrap(
+            data
+        ), "Failure on non-wide char longword regression check for width " + str(width)
+
+
+def test_wrap_wide_char_multiword():
+    """TextWrapper: wrapping support for wide characters with multiple words"""
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_wrap_wide_char is skipped")
+
+    data = "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다"
+
+    expected = ["약간 감싸면 더", "잘 보일 수있는", "다소 긴", "설명입니다"]
+
+    wrapper = CTW(width=15)
+    result = wrapper.wrap(data)
+    assert_equal(expected, result)
+
+
+def test_wrap_wide_char_longword():
+    """TextWrapper: wrapping wide char word that needs to be broken up"""
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_wrap_wide_char_longword is skipped")
+
+    data = "약간감싸면더잘보일수있"
+
+    expected = ["약간", "감싸", "면더", "잘보", "일수", "있"]
+
+    # Explicit odd number to ensure the 2 width is taken into account
+    wrapper = CTW(width=5)
+    result = wrapper.wrap(data)
+    assert_equal(expected, result)
+
+
+def test_wrap_mixed_string():
+    """TextWrapper: wrapping string with mix of wide and non-wide chars"""
+    try:
+        import wcwidth  # noqa
+    except ImportError:
+        skip("test_wrap_wide_char is skipped")
+
+    data = (
+        "This content of this string (この文字列のこの内容) contains "
+        "multiple character types (複数の文字タイプが含まれています)"
+    )
+
+    expected = [
+        "This content of this",
+        "string (この文字列の",
+        "この内容) contains",
+        "multiple character",
+        "types (複数の文字タイ",
+        "プが含まれています)",
+    ]
+    wrapper = CTW(width=21)
+    result = wrapper.wrap(data)
+    assert_equal(expected, result)
+
+
+def test_wrapper_len_ignores_color_chars():
+    data = "\033[31m\033[104mtenletters\033[0m"
+    result = CTW._len(data)
+    assert_equal(10, result)
+
+
+def test_wrap_full_line_color():
+    """TextWrapper: Wrap a line when the full thing is enclosed in color tags"""
+    # This has both a text color and a background color
+    data = (
+        "\033[31m\033[104mThis is a test string for testing TextWrap with colors\033[0m"
+    )
+
+    expected = [
+        "\033[31m\033[104mThis is a test\033[0m",
+        "\033[31m\033[104mstring for testing\033[0m",
+        "\033[31m\033[104mTextWrap with colors\033[0m",
+    ]
+    wrapper = CTW(width=20)
+    result = wrapper.wrap(data)
+    assert_equal(expected, result)
+
+
+def test_wrap_color_in_single_line():
+    """TextWrapper: Wrap a line - preserve internal color tags, and don't
+    propagate them to other lines when they don't need to be"""
+    # This has both a text color and a background color
+    data = "This is a test string for testing \033[31mTextWrap\033[0m with colors"
+
+    expected = [
+        "This is a test string for",
+        "testing \033[31mTextWrap\033[0m with",
+        "colors",
+    ]
+    wrapper = CTW(width=25)
+    result = wrapper.wrap(data)
+    assert_equal(expected, result)
+
+
+def test_wrap_color_line_splillover():
+    """TextWrapper: Wrap a line - preserve internal color tags and wrap them to
+    other lines when required, requires adding the colors tags to other lines as appropriate
+    """
+    # This has both a text color and a background color
+    data = "This is a \033[31mtest string for testing TextWrap\033[0m with colors"
+
+    expected = [
+        "This is a \033[31mtest string for\033[0m",
+        "\033[31mtesting TextWrap\033[0m with",
+        "colors",
+    ]
+    wrapper = CTW(width=25)
+    result = wrapper.wrap(data)
+    assert_equal(expected, result)
+
+
+def test_wrap_datetime():
+    """TextWrapper: Show that datetimes can be wrapped without crashing"""
+    data = [
+        ["First Entry", datetime.datetime(2020, 1, 1, 5, 6, 7)],
+        ["Second Entry", datetime.datetime(2021, 2, 2, 0, 0, 0)],
+    ]
+    headers = ["Title", "When"]
+    result = tabulate(data, headers=headers, tablefmt="grid", maxcolwidths=[7, 5])
+
+    expected = [
+        "+---------+--------+",
+        "| Title   | When   |",
+        "+=========+========+",
+        "| First   | 2020-  |",
+        "| Entry   | 01-01  |",
+        "|         | 05:06  |",
+        "|         | :07    |",
+        "+---------+--------+",
+        "| Second  | 2021-  |",
+        "| Entry   | 02-02  |",
+        "|         | 00:00  |",
+        "|         | :00    |",
+        "+---------+--------+",
+    ]
+    expected = "\n".join(expected)
+    assert_equal(expected, result)