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.

Hints from issue comments:

Solution patch:
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/github-actions-tox.yml b/.github/workflows/tabulate.yml
similarity index 71%
rename from .github/workflows/github-actions-tox.yml
rename to .github/workflows/tabulate.yml
index 35be5cd..9adabb7 100644
--- a/.github/workflows/github-actions-tox.yml
+++ b/.github/workflows/tabulate.yml
@@ -12,7 +12,7 @@ jobs:
         python-version: ['3.9', '3.10', '3.11', '3.12']
 
     steps:
-    - uses: actions/checkout@v3
+    - uses: actions/checkout@v4
     - name: Set up Python ${{ matrix.python-version }}
       uses: actions/setup-python@v4
       with:
@@ -20,6 +20,7 @@ jobs:
     - name: Install dependencies
       run: |
         python -m pip install --upgrade pip
-        python -m pip install tox tox-gh-actions
-    - name: Test with tox
-      run: tox
+        python -m pip install pytest numpy pandas
+    - name: Run tests
+      run: |
+        pytest -v --doctest-modules --ignore benchmark.py
diff --git a/README.md b/README.md
index 39c3863..b596cf5 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)
+[![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
 -------------
diff --git a/tabulate/__init__.py b/tabulate/__init__.py
index 3883b96..fc45064 100644
--- a/tabulate/__init__.py
+++ b/tabulate/__init__.py
@@ -104,7 +104,7 @@ def _is_file(f):
 
 
 def _is_separating_line_value(value):
-    return type(value) == str and value.strip() == SEPARATING_LINE
+    return type(value) is str and value.strip() == SEPARATING_LINE
 
 
 def _is_separating_line(row):
@@ -140,6 +140,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": "",
@@ -406,6 +430,16 @@ 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("+", "=", "+", "+"),
@@ -699,6 +733,7 @@ def escape_empty(val):
     "mixed_grid": "mixed_grid",
     "double_grid": "double_grid",
     "fancy_grid": "fancy_grid",
+    "colon_grid": "colon_grid",
     "pipe": "pipe",
     "orgtbl": "orgtbl",
     "jira": "jira",
@@ -1838,6 +1873,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 +2213,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
     #
@@ -2222,7 +2288,7 @@ def tabulate(
         aligns = [colglobalalign] * len(cols)
     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):
@@ -2239,9 +2305,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
@@ -2253,7 +2324,7 @@ def tabulate(
             aligns_headers = [headersglobalalign] * len(t_cols)
         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):
@@ -2770,7 +2841,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
diff --git a/tox.ini b/tox.ini
index 92939c2..a7fe513 100644
--- a/tox.ini
+++ b/tox.ini
@@ -11,12 +11,12 @@
 envlist = lint, py{38, 39, 310, 311, 312}
 isolated_build = True
 
-[gh-actions]
+[gh]
 python =
-    3.9: py39
-    3.10: py310
-    3.11: py311
-    3.12: py312
+    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 {posargs}


Test patch:
diff --git a/test/test_cli.py b/test/test_cli.py
index ce85f19..e71572d 100644
--- a/test/test_cli.py
+++ b/test/test_cli.py
@@ -2,7 +2,6 @@
 
 """
 
-
 import os
 import sys
 
diff --git a/test/test_internal.py b/test/test_internal.py
index 64e1d12..e7564d3 100644
--- a/test/test_internal.py
+++ b/test/test_internal.py
@@ -182,7 +182,12 @@ def test_wrap_text_wide_chars():
 
     rows = [["청자청자청자청자청자", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다"]]
     widths = [5, 20]
-    expected = [["청자\n청자\n청자\n청자\n청자", "약간 감싸면 더 잘\n보일 수있는 다소 긴\n설명입니다"]]
+    expected = [
+        [
+            "청자\n청자\n청자\n청자\n청자",
+            "약간 감싸면 더 잘\n보일 수있는 다소 긴\n설명입니다",
+        ]
+    ]
     result = T._wrap_text_to_colwidths(rows, widths)
 
     assert_equal(expected, result)
diff --git a/test/test_output.py b/test/test_output.py
index ee03522..ed565a8 100644
--- a/test/test_output.py
+++ b/test/test_output.py
@@ -137,7 +137,10 @@ def test_plain_maxcolwidth_autowraps_wide_chars():
 
     table = [
         ["hdr", "fold"],
-        ["1", "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다 설명입니다 설명입니다 설명입니다 설명"],
+        [
+            "1",
+            "약간 감싸면 더 잘 보일 수있는 다소 긴 설명입니다 설명입니다 설명입니다 설명입니다 설명",
+        ],
     ]
     expected = "\n".join(
         [
@@ -1415,6 +1418,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(
diff --git a/test/test_textwrapper.py b/test/test_textwrapper.py
index 88420be..b315a5c 100644
--- a/test/test_textwrapper.py
+++ b/test/test_textwrapper.py
@@ -142,7 +142,8 @@ def test_wrap_color_in_single_line():
 
 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"""
+    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"