Repository: astanin/python-tabulate

Issue:
Add new Pandoc grid table format
The current `"grid"` table format in Tabulate's documentation says:

> It corresponds to grid_tables in Pandoc Markdown extensions

It is true that the tables are _similar_ to each other, but there is a nasty interaction between the padding that Tabulate provides and how [Pandoc interprets grid tables](https://pandoc.org/chunkedhtml-demo/8.9-tables.html#extension-grid_tables). Specifically, the documentation for Pandoc's `grid_tables` states:

> The cells of grid tables may contain arbitrary block elements (multiple paragraphs, code blocks, lists, etc.).

So each cell in the grid table is treated as essentially a new Markdown scope. [In the Markdown spec](https://daringfireball.net/projects/markdown/syntax#precode), Markdown indented by 4 spaces is treated as a verbatim code block. For a column of integers, Tabulate will default to right-aligning the content in the cells, which Pandoc then interprets as a verbatim code block instead of standard text. For example:

```markdown
+------------+------------+------------+
|   Column 1 | Column 2   |   Column 3 |
+============+============+============+
|          1 | a          |       1.23 |
+------------+------------+------------+
|          2 | b          |      12.3  |
+------------+------------+------------+
|          3 | c          |     123    |
+------------+------------+------------+
```

is rendered as:

![image](https://github.com/astanin/python-tabulate/assets/15197528/71a74103-4560-4f52-bd3d-163171344a3d)

Forcing Tabulate to left-align the cells does allow it to render properly:

```markdown
+------------+------------+------------+
| Column 1   | Column 2   | Column 3   |
+============+============+============+
| 1          | a          | 1.23       |
+------------+------------+------------+
| 2          | b          | 12.3       |
+------------+------------+------------+
| 3          | c          | 123        |
+------------+------------+------------+
```

![image](https://github.com/astanin/python-tabulate/assets/15197528/aa953ba1-70ef-4e02-81ec-1d3d7b75826a)

But Pandoc does support syntax for right-aligning cell content (using a colon, similar to `pipe` tables):

```markdown
+------------+------------+------------+
| Column 1   | Column 2   | Column 3   |
+===========:+============+===========:+
| 1          | a          | 1.23       |
+------------+------------+------------+
| 2          | b          | 12.3       |
+------------+------------+------------+
| 3          | c          | 123        |
+------------+------------+------------+
```

![image](https://github.com/astanin/python-tabulate/assets/15197528/064c2912-23cc-471d-ae7b-fa895a97ccfe)

I think Tabulate should provide a new output format, `pandoc_grid` or `grid_tables` or something that produces a table similar to existing `grid` tables, but with cell content always left-aligned and inserting appropriate `:`s in the row below the header to select the alignment that Pandoc will use.

Discussion:

Patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
new file mode 100644
index 0000000..ac9c525
--- /dev/null
+++ b/.circleci/config.yml
@@ -0,0 +1,60 @@
+# 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: build wheel
+          command: |
+            . venv/bin/activate
+            python -m build -nwx .
+
+      - store_artifacts:
+          path: dist
+          destination: dist
+
+      - run:
+          name: run tests
+          command: |
+            . venv/bin/activate
+            tox -e py38-extra
+
+      - store_artifacts:
+          path: test-reports
+          destination: test-reports
diff --git a/.circleci/requirements.txt b/.circleci/requirements.txt
new file mode 100644
index 0000000..ea5e80c
--- /dev/null
+++ b/.circleci/requirements.txt
@@ -0,0 +1,10 @@
+pytest
+tox
+numpy
+pandas
+wcwidth
+setuptools
+pip
+build
+wheel
+setuptools_scm
diff --git a/.github/workflows/github-actions-tox.yml b/.github/workflows/github-actions-tox.yml
deleted file mode 100644
index 35be5cd..0000000
--- a/.github/workflows/github-actions-tox.yml
+++ /dev/null
@@ -1,25 +0,0 @@
-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@v3
-    - 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-actions
-    - name: Test with tox
-      run: tox
diff --git a/HOWTOPUBLISH b/HOWTOPUBLISH
index 795fc73..5be16ed 100644
--- a/HOWTOPUBLISH
+++ b/HOWTOPUBLISH
@@ -1,7 +1,7 @@
 # update contributors and CHANGELOG in README
 # tag version release
 python3 benchmark.py  # then update README
-tox -e py38-extra,py39-extra,py310-extra,py311-extra,py312-extra
+tox -e py37-extra,py38-extra,py39-extra,py310-extra
 python3 -m build -nswx .
 twine upload --repository-url https://test.pypi.org/legacy/ dist/*
 twine upload dist/*
diff --git a/README.md b/README.md
index 39c3863..07ab28c 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,7 @@ pip install tabulate
 Build status
 ------------
 
-[![python-tabulate](https://github.com/astanin/python-tabulate/actions/workflows/github-actions-tox.yml/badge.svg)](https://github.com/astanin/python-tabulate/actions/workflows/github-actions-tox.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)
+[![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)
 
 Library usage
 -------------
@@ -81,7 +81,7 @@ 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)
+-   list of dataclasses (Python 3.7+ only, field names as columns)
 -   two-dimensional NumPy array
 -   NumPy record arrays (names as columns)
 -   pandas.DataFrame
@@ -121,22 +121,10 @@ dictionaries or named tuples:
 ```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
+  Age  Name
+-----  ------
+   24  Alice
+   19  Bob
 ```
 
 ### Row Indices
@@ -1086,14 +1074,14 @@ 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 `python3.11`, `python3.12` etc.
-On Windows it looks for `C:\Python311\python.exe`, `C:\Python312\python.exe` etc. respectively.
+On Linux `tox` expects to find executables like `python3.7`, `python3.8` etc.
+On Windows it looks for `C:\Python37\python.exe`, `C:\Python38\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
+     pyenv install 3.7.12
+     pyenv install 3.8.12
      ...
 
 Don't forget to change your `PATH` so that `tox` knows how to find all the installed versions. Something like
@@ -1101,10 +1089,10 @@ Don't forget to change your `PATH` so that `tox` knows how to find all the insta
      export PATH="${PATH}:${HOME}/.pyenv/shims"
 
 To test only some Python environments, use `-e` option. For example, to
-test only against Python 3.11 and Python 3.12, run:
+test only against Python 3.7 and Python 3.10, run:
 
 ```shell
-tox -e py311,py312
+tox -e py37,py310
 ```
 
 in the root of the project source tree.
@@ -1112,7 +1100,7 @@ in the root of the project source tree.
 To enable NumPy and Pandas tests, run:
 
 ```shell
-tox -e py311-extra,py312-extra
+tox -e py37-extra,py310-extra
 ```
 
 (this may take a long time the first time, because NumPy and Pandas will
diff --git a/appveyor.yml b/appveyor.yml
index 12bd772..4eb2dd8 100644
--- a/appveyor.yml
+++ b/appveyor.yml
@@ -8,14 +8,14 @@ environment:
     # The list here is complete (excluding Python 2.6, which
     # isn't covered by this document) at the time of writing.
 
-    #- PYTHON: "C:\\Python39"
-    #- PYTHON: "C:\\Python310"
-    #- PYTHON: "C:\\Python311"
-    #- PYTHON: "C:\\Python312"
+    - PYTHON: "C:\\Python37"
+    - PYTHON: "C:\\Python38"
+    - PYTHON: "C:\\Python39"
+    - PYTHON: "C:\\Python37-x64"
+    - PYTHON: "C:\\Python38-x64"
     - 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
@@ -29,6 +29,9 @@ 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.
@@ -37,7 +40,9 @@ test_script:
 
 after_test:
   # This step builds your wheels.
-  # Again, you need to use %PYTHON% to get the correct interpreter
+  # 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
   #- "build.cmd %PYTHON%\\python.exe setup.py bdist_wheel"
   - "%PYTHON%\\python.exe -m build -nswx ."
 
diff --git a/pyproject.toml b/pyproject.toml
index 8cb6216..5a8c1fd 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,13 +13,14 @@ classifiers = [
     "License :: OSI Approved :: MIT License",
     "Operating System :: OS Independent",
     "Programming Language :: Python :: 3",
+    "Programming Language :: Python :: 3.7",
+    "Programming Language :: Python :: 3.8",
     "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"
+requires-python = ">=3.7"
 dynamic = ["version"]
 
 [project.urls]
diff --git a/tabulate/__init__.py b/tabulate/__init__.py
index 3883b96..b0d9612 100644
--- a/tabulate/__init__.py
+++ b/tabulate/__init__.py
@@ -11,7 +11,6 @@ import re
 import math
 import textwrap
 import dataclasses
-import sys
 
 try:
     import wcwidth  # optional wide-character (CJK) support
@@ -103,17 +102,12 @@ TableFormat = namedtuple(
 )
 
 
-def _is_separating_line_value(value):
-    return type(value) == 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]))
+        (len(row) >= 1 and row[0] == SEPARATING_LINE)
+        or (len(row) >= 2 and row[1] == SEPARATING_LINE)
     )
-
     return is_sl
 
 
@@ -140,6 +134,30 @@ 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": "",
@@ -230,7 +248,7 @@ def _asciidoc_row(is_header, *args):
             colwidths, [alignment[colalign] for colalign in colaligns]
         )
         asciidoc_column_specifiers = [
-            f"{width:d}{align}" for width, align in asciidoc_alignments
+            "{:d}{}".format(width, align) for width, align in asciidoc_alignments
         ]
         header_list = ['cols="' + (",".join(asciidoc_column_specifiers)) + '"']
 
@@ -406,6 +424,16 @@ _table_formats = {
         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("+", "=", "+", "+"),
@@ -699,6 +727,7 @@ multiline_formats = {
     "mixed_grid": "mixed_grid",
     "double_grid": "double_grid",
     "fancy_grid": "fancy_grid",
+    "colon_grid": "colon_grid",
     "pipe": "pipe",
     "orgtbl": "orgtbl",
     "jira": "jira",
@@ -1235,17 +1264,6 @@ def _format(val, valtype, floatfmt, intfmt, missingval="", has_invisible=True):
     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:
@@ -1288,7 +1306,7 @@ def _align_header(
 
 
 def _remove_separating_lines(rows):
-    if isinstance(rows, list):
+    if type(rows) == list:
         separating_lines = []
         sans_rows = []
         for index, row in enumerate(rows):
@@ -1314,7 +1332,7 @@ def _prepend_row_index(rows, index):
     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)}"
+            + "len(index)={} len(rows)={}".format(len(index), len(rows))
         )
     sans_rows, separating_lines = _remove_separating_lines(rows)
     new_rows = []
@@ -1336,8 +1354,7 @@ 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,
-    with headers padding.
+    """Transform a supported data type to a list of lists, and a list of headers, with headers padding.
 
     Supported tabular data types:
 
@@ -1349,7 +1366,7 @@ 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")
+    * list of dataclasses (Python 3.7+ only, usually used with headers="keys")
 
     * 2D NumPy arrays
 
@@ -1371,7 +1388,9 @@ 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)
 
     index = None
@@ -1473,7 +1492,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"):
             and len(rows) > 0
             and dataclasses.is_dataclass(rows[0])
         ):
-            # Python's dataclass
+            # Python 3.7+'s dataclass
             field_names = [field.name for field in dataclasses.fields(rows[0])]
             if headers == "keys":
                 headers = field_names
@@ -1616,7 +1635,7 @@ 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,
-    an iterable of dataclasses, a two-dimensional NumPy array,
+    an iterable of dataclasses (Python 3.7+), a two-dimensional NumPy array,
     NumPy record array, or a Pandas' dataframe.
 
 
@@ -1838,6 +1857,30 @@ 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"]],
@@ -2154,6 +2197,13 @@ 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
     #
@@ -2218,19 +2268,15 @@ def tabulate(
 
     # align columns
     # first set global alignment
-    if colglobalalign is not None:  # if global alignment provided
+    if colglobalalign is not None: # if global alignment provided
         aligns = [colglobalalign] * len(cols)
-    else:  # default
+    else: # default
         aligns = [numalign if ct in [int, float] else stralign for ct in coltypes]
-    # then specific alignements
+    # 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,
-            )
+            warnings.warn(f"As a string, `colalign` is interpreted as {[c for c in colalign]}. Did you mean `colglobalalign = \"{colalign}\"` or `colalign = (\"{colalign}\",)`?", stacklevel=2)
         for idx, align in enumerate(colalign):
             if not idx < len(aligns):
                 break
@@ -2239,9 +2285,14 @@ def tabulate(
     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)
+        for c, a, minw in zip(cols, aligns_copy, minwidths)
     ]
 
     aligns_headers = None
@@ -2249,25 +2300,20 @@ def tabulate(
         # align headers and add headers
         t_cols = cols or [[""]] * len(headers)
         # first set global alignment
-        if headersglobalalign is not None:  # if global alignment provided
+        if headersglobalalign is not None: # if global alignment provided
             aligns_headers = [headersglobalalign] * len(t_cols)
-        else:  # default
+        else: # default
             aligns_headers = aligns or [stralign] * len(headers)
-        # then specific header alignements
+        # 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,
-                )
+                warnings.warn(f"As a string, `headersalign` is interpreted as {[c for c in headersalign]}. Did you mean `headersglobalalign = \"{headersalign}\"` 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
+                elif align == "same" and hidx < len(aligns): # same as column align
                     aligns_headers[hidx] = aligns[hidx]
                 elif align != "global":
                     aligns_headers[hidx] = align
@@ -2292,14 +2338,7 @@ def tabulate(
     _reinsert_separating_lines(rows, separating_lines)
 
     return _format_table(
-        tablefmt,
-        headers,
-        aligns_headers,
-        rows,
-        minwidths,
-        aligns,
-        is_multiline,
-        rowaligns=rowaligns,
+        tablefmt, headers, aligns_headers, rows, minwidths, aligns, is_multiline, rowaligns=rowaligns
     )
 
 
@@ -2430,9 +2469,7 @@ class JupyterHTMLStr(str):
         return self
 
 
-def _format_table(
-    fmt, headers, headersaligns, rows, colwidths, colaligns, is_multiline, rowaligns
-):
+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 []
@@ -2728,6 +2765,8 @@ def _main():
                               (default: simple)
     """
     import getopt
+    import sys
+    import textwrap
 
     usage = textwrap.dedent(_main.__doc__)
     try:
diff --git a/tox.ini b/tox.ini
index 92939c2..c6260d2 100644
--- a/tox.ini
+++ b/tox.ini
@@ -8,16 +8,9 @@
 # for testing and it is disabled by default.
 
 [tox]
-envlist = lint, py{38, 39, 310, 311, 312}
+envlist = lint, py{37, 38, 39, 310, 311}
 isolated_build = True
 
-[gh-actions]
-python =
-    3.9: py39
-    3.10: py310
-    3.11: py311
-    3.12: py312
-
 [testenv]
 commands = pytest -v --doctest-modules --ignore benchmark.py {posargs}
 deps =
@@ -32,6 +25,21 @@ commands = python -m pre_commit run -a
 deps =
     pre-commit
 
+[testenv:py37]
+basepython = python3.7
+commands = pytest -v --doctest-modules --ignore benchmark.py {posargs}
+deps =
+    pytest
+
+[testenv:py37-extra]
+basepython = python3.7
+commands = pytest -v --doctest-modules --ignore benchmark.py {posargs}
+deps =
+    pytest
+    numpy
+    pandas
+    wcwidth
+
 [testenv:py38]
 basepython = python3.8
 commands = pytest -v --doctest-modules --ignore benchmark.py {posargs}
@@ -97,21 +105,6 @@ deps =
     pandas
     wcwidth
 
-[testenv:py312]
-basepython = python3.12
-commands = pytest -v --doctest-modules --ignore benchmark.py {posargs}
-deps =
-    pytest
-
-[testenv:py312-extra]
-basepython = python3.12
-setenv = PYTHONDEVMODE = 1
-commands = pytest -v --doctest-modules --ignore benchmark.py {posargs}
-deps =
-    pytest
-    numpy
-    pandas
-    wcwidth
 
 [flake8]
 max-complexity = 22


Test patch:
diff --git a/test/common.py b/test/common.py
index ca5a069..4cd3709 100644
--- a/test/common.py
+++ b/test/common.py
@@ -7,6 +7,7 @@ def assert_equal(expected, result):
     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):
@@ -27,7 +28,6 @@ def rows_to_pipe_table_str(rows):
 
     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:
@@ -41,3 +41,4 @@ def check_warnings(func_args_kwargs, *, num=None, category=None, contain=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_input.py b/test/test_input.py
index 721d03a..a178bd9 100644
--- a/test/test_input.py
+++ b/test/test_input.py
@@ -522,7 +522,7 @@ def test_py37orlater_list_of_dataclasses_headers():
 
 def test_list_bytes():
     "Input: a list of bytes. (issue #192)"
-    lb = [["你好".encode()], ["你好"]]
+    lb = [["你好".encode("utf-8")], ["你好"]]
     expected = "\n".join(
         ["bytes", "---------------------------", r"b'\xe4\xbd\xa0\xe5\xa5\xbd'", "你好"]
     )
diff --git a/test/test_output.py b/test/test_output.py
index ee03522..25b99a9 100644
--- a/test/test_output.py
+++ b/test/test_output.py
@@ -1,5 +1,4 @@
 """Test output of the various forms of tabular data."""
-from pytest import mark
 
 import tabulate as tabulate_module
 from common import assert_equal, raises, skip, check_warnings
@@ -1415,6 +1414,97 @@ def test_fancy_grid_multiline_row_align():
     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(
@@ -2639,44 +2729,6 @@ def test_intfmt():
     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 = ""
@@ -2719,72 +2771,60 @@ def test_colalign_multi_with_sep_line():
     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")
+    """ 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",
-            "---  ---  ---  ---",
-        ]
-    )
+    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",
-        ]
-    )
+    """ 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"])
+    """ 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"
-    )
-
+    """ 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"
@@ -2986,27 +3026,6 @@ def test_list_of_lists_with_index_with_sep_line():
     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():
     "Output: a table with a supplied index"
     dd = zip(*[list(range(3)), list(range(101, 104))])
diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py
index 88420be..f3070b1 100644
--- a/test/test_textwrapper.py
+++ b/test/test_textwrapper.py
@@ -1,4 +1,6 @@
 """Discretely test functionality of our custom TextWrapper"""
+from __future__ import unicode_literals
+
 import datetime
 
 from tabulate import _CustomTextWrap as CTW, tabulate