Repository: astanin/python-tabulate

Issue:
Documentation missing for using a dictionary as the header for a list of dictionaries
When using tabulate with a list of dictionaries, it gives an error if the header is not a keyword or a dictionary.  There is documentation for the keyword options but nothing for the dictionary as header option.

A short explanation and an example would be very helpful e.g.:

When tabulating a list of dictionaries, a dictionary can be used to replace the labels for keys in your list of dictionaries - for example:

```
samplelistdict=[ { "key1" : "value1", "key2": "value2" },{ "key1" : "value11", "key2": "value12" } ]
sampleheaderdict={"key1": "Bettername", "key2": "Better2"}
print(tabulate(samplelistdict, sampleheaderdict))
```
Produces:
```
Bettername    Better2
------------  ---------
value1        value2
value11       value12
```

Hints from issue comments:
@astanin: When this feature is documented, it may be useful to suggest use of the ordered dictionaries. Using an ordinary dictionary for headers or data confuses some users when they don't see columns in the same order they expected them to be seen.@ilya112358: Actually, python preserves the insertion order of dictionaries since 3.7 (officially) so they are safe to use now. 

This means the example in README about using keys in headers should be changed since the column labels are swapped there which should no longer happen.

I did this in the same PR which fixes the current issue.

Solution patch:
diff --git a/.circleci/config.yml b/.circleci/config.yml
deleted file mode 100644
index ac9c525..0000000
--- a/.circleci/config.yml
+++ /dev/null
@@ -1,60 +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: 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
deleted file mode 100644
index ea5e80c..0000000
--- a/.circleci/requirements.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-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
new file mode 100644
index 0000000..35be5cd
--- /dev/null
+++ b/.github/workflows/github-actions-tox.yml
@@ -0,0 +1,25 @@
+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 5be16ed..795fc73 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 py37-extra,py38-extra,py39-extra,py310-extra
+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/*
diff --git a/README.md b/README.md
index d64b99a..39c3863 100644
--- a/README.md
+++ b/README.md
@@ -53,7 +53,7 @@ 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/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)
 
 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 (Python 3.7+ only, field names as columns)
+-   list of dataclasses (field names as columns)
 -   two-dimensional NumPy array
 -   NumPy record arrays (names as columns)
 -   pandas.DataFrame
@@ -121,10 +121,22 @@ dictionaries or named tuples:
 ```pycon
 >>> print(tabulate({"Name": ["Alice", "Bob"],
 ...                 "Age": [24, 19]}, headers="keys"))
-  Age  Name
------  ------
-   24  Alice
-   19  Bob
+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
@@ -666,18 +678,31 @@ 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`.
 
 ```pycon
->>> print(tabulate([["one", "two"], ["three", "four"]], colalign=("right",))
------  ----
-  one  two
-three  four
------  ----
+>>> 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
@@ -1061,14 +1086,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.7`, `python3.8` etc.
-On Windows it looks for `C:\Python37\python.exe`, `C:\Python38\python.exe` etc. 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.7.12
-     pyenv install 3.8.12
+     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
@@ -1076,10 +1101,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.7 and Python 3.10, run:
+test only against Python 3.11 and Python 3.12, run:
 
 ```shell
-tox -e py37,py310
+tox -e py311,py312
 ```
 
 in the root of the project source tree.
@@ -1087,7 +1112,7 @@ in the root of the project source tree.
 To enable NumPy and Pandas tests, run:
 
 ```shell
-tox -e py37-extra,py310-extra
+tox -e py311-extra,py312-extra
 ```
 
 (this may take a long time the first time, because NumPy and Pandas will
@@ -1123,5 +1148,5 @@ 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.
+Dimitri Papadopoulos, Élie Goudout.
 
diff --git a/appveyor.yml b/appveyor.yml
index 4eb2dd8..12bd772 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:\\Python37"
-    - PYTHON: "C:\\Python38"
-    - PYTHON: "C:\\Python39"
-    - 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
@@ -29,9 +29,6 @@ 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.
@@ -40,9 +37,7 @@ 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 -m build -nswx ."
 
diff --git a/pyproject.toml b/pyproject.toml
index 5a8c1fd..8cb6216 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -13,14 +13,13 @@ 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.7"
+requires-python = ">=3.9"
 dynamic = ["version"]
 
 [project.urls]
diff --git a/tabulate/__init__.py b/tabulate/__init__.py
index 3b1a1e1..3883b96 100644
--- a/tabulate/__init__.py
+++ b/tabulate/__init__.py
@@ -1,5 +1,6 @@
 """Pretty-print tabular data."""
 
+import warnings
 from collections import namedtuple
 from collections.abc import Iterable, Sized
 from html import escape as htmlescape
@@ -10,6 +11,7 @@
 import math
 import textwrap
 import dataclasses
+import sys
 
 try:
     import wcwidth  # optional wide-character (CJK) support
@@ -101,12 +103,17 @@ def _is_file(f):
 )
 
 
+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 row[0] == SEPARATING_LINE)
-        or (len(row) >= 2 and row[1] == SEPARATING_LINE)
+        (len(row) >= 1 and _is_separating_line_value(row[0]))
+        or (len(row) >= 2 and _is_separating_line_value(row[1]))
     )
+
     return is_sl
 
 
@@ -223,7 +230,7 @@ def make_header_line(is_header, colwidths, colaligns):
             colwidths, [alignment[colalign] for colalign in colaligns]
         )
         asciidoc_column_specifiers = [
-            "{:d}{}".format(width, align) for width, align in asciidoc_alignments
+            f"{width:d}{align}" for width, align in asciidoc_alignments
         ]
         header_list = ['cols="' + (",".join(asciidoc_column_specifiers)) + '"']
 
@@ -1228,6 +1235,17 @@ 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:
@@ -1270,7 +1288,7 @@ def _align_header(
 
 
 def _remove_separating_lines(rows):
-    if type(rows) == list:
+    if isinstance(rows, list):
         separating_lines = []
         sans_rows = []
         for index, row in enumerate(rows):
@@ -1296,7 +1314,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: "
-            + "len(index)={} len(rows)={}".format(len(index), len(rows))
+            + f"len(index)={len(index)} len(rows)={len(rows)}"
         )
     sans_rows, separating_lines = _remove_separating_lines(rows)
     new_rows = []
@@ -1318,7 +1336,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:
 
@@ -1330,7 +1349,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"):
 
     * list of OrderedDicts (usually used with headers="keys")
 
-    * list of dataclasses (Python 3.7+ only, usually used with headers="keys")
+    * list of dataclasses (usually used with headers="keys")
 
     * 2D NumPy arrays
 
@@ -1352,9 +1371,7 @@ 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
@@ -1456,7 +1473,7 @@ def _normalize_tabular_data(tabular_data, headers, showindex="default"):
             and len(rows) > 0
             and dataclasses.is_dataclass(rows[0])
         ):
-            # Python 3.7+'s dataclass
+            # Python's dataclass
             field_names = [field.name for field in dataclasses.fields(rows[0])]
             if headers == "keys":
                 headers = field_names
@@ -1498,13 +1515,12 @@ 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
+    return rows, headers, headers_pad
 
 
 def _wrap_text_to_colwidths(list_of_lists, colwidths, numparses=True):
@@ -1580,8 +1596,11 @@ def tabulate(
     missingval=_DEFAULT_MISSINGVAL,
     showindex="default",
     disable_numparse=False,
+    colglobalalign=None,
     colalign=None,
     maxcolwidths=None,
+    headersglobalalign=None,
+    headersalign=None,
     rowalign=None,
     maxheadercolwidths=None,
 ):
@@ -1597,7 +1616,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 (Python 3.7+), a two-dimensional NumPy array,
+    an iterable of dataclasses, a two-dimensional NumPy array,
     NumPy record array, or a Pandas' dataframe.
 
 
@@ -1636,8 +1655,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
@@ -1646,6 +1665,23 @@ 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
     -------------
@@ -2065,7 +2101,7 @@ def tabulate(
     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)
@@ -2181,11 +2217,25 @@ def tabulate(
     ]
 
     # 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 alignements
     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)
     )
@@ -2194,17 +2244,40 @@ def tabulate(
         for c, a, minw in zip(cols, aligns, 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 alignements
+        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:
@@ -2219,7 +2292,14 @@ def tabulate(
     _reinsert_separating_lines(rows, separating_lines)
 
     return _format_table(
-        tablefmt, headers, rows, minwidths, aligns, is_multiline, rowaligns=rowaligns
+        tablefmt,
+        headers,
+        aligns_headers,
+        rows,
+        minwidths,
+        aligns,
+        is_multiline,
+        rowaligns=rowaligns,
     )
 
 
@@ -2350,7 +2430,9 @@ def str(self):
         return self
 
 
-def _format_table(fmt, headers, 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 []
@@ -2372,7 +2454,7 @@ def _format_table(fmt, headers, rows, colwidths, colaligns, is_multiline, rowali
         _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)
 
@@ -2646,8 +2728,6 @@ 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 c6260d2..92939c2 100644
--- a/tox.ini
+++ b/tox.ini
@@ -8,9 +8,16 @@
 # for testing and it is disabled by default.
 
 [tox]
-envlist = lint, py{37, 38, 39, 310, 311}
+envlist = lint, py{38, 39, 310, 311, 312}
 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 =
@@ -25,21 +32,6 @@ 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}
@@ -105,6 +97,21 @@ 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 d95e84f..ca5a069 100644
--- a/test/common.py
+++ b/test/common.py
@@ -1,13 +1,12 @@
 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):
@@ -27,3 +26,18 @@ def rows_to_pipe_table_str(rows):
         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 046d752..e658e82 100644
--- a/test/test_api.py
+++ b/test/test_api.py
@@ -48,8 +48,11 @@ def test_tabulate_signature():
         ("missingval", ""),
         ("showindex", "default"),
         ("disable_numparse", False),
+        ("colglobalalign", None),
         ("colalign", None),
         ("maxcolwidths", None),
+        ("headersglobalalign", None),
+        ("headersalign", None),
         ("rowalign", None),
         ("maxheadercolwidths", None),
     ]
diff --git a/test/test_input.py b/test/test_input.py
index a178bd9..721d03a 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("utf-8")], ["你好"]]
+    lb = [["你好".encode()], ["你好"]]
     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 9043aed..ee03522 100644
--- a/test/test_output.py
+++ b/test/test_output.py
@@ -1,7 +1,8 @@
 """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
+from common import assert_equal, raises, skip, check_warnings
 from tabulate import tabulate, simple_separated_format, SEPARATING_LINE
 
 # _test_table shows
@@ -2638,6 +2639,44 @@ 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 = ""
@@ -2681,6 +2720,72 @@ def test_colalign_multi_with_sep_line():
     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"]
@@ -2881,6 +2986,27 @@ 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 f3070b1..c8feded 100644
--- a/test/test_textwrapper.py
+++ b/test/test_textwrapper.py
@@ -1,5 +1,4 @@
 """Discretely test functionality of our custom TextWrapper"""
-from __future__ import unicode_literals
 
 import datetime