diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 861e42d..01ee7f7 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -94,7 +94,7 @@ jobs:
setup.py
- name: Install dependencies
- run: python -m pip install -e ".[dev]" -e ".[docs]"
+ run: python -m pip install -e ".[dev,docs]"
- name: Run mypy
run: mypy .
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 5c6b8e0..293d837 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -12,7 +12,7 @@ repos:
name: Running black in all files.
- repo: https://github.com/pycqa/isort
- rev: 5.11.1
+ rev: 5.11.4
hooks:
- id: isort
args: ["--profile", "black", "--extend-skip", "table2ascii"]
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 91b025b..d99b2e0 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -24,6 +24,18 @@ Install documentation dependencies with:
pip install -e ".[docs]"
```
+Install runtime dependencies with:
+
+```bash
+pip install -e .
+```
+
+All dependencies can be installed at once with:
+
+```bash
+pip install -e ".[dev,docs]"
+```
+
### Running the Tests
Run the following command to run the [Tox](https://github.com/tox-dev/tox) test script which will verify that the tested functionality is still working.
diff --git a/README.md b/README.md
index ffc0261..b9ff0a7 100644
--- a/README.md
+++ b/README.md
@@ -69,22 +69,26 @@ print(output)
from table2ascii import table2ascii, Alignment
output = table2ascii(
- header=["#", "G", "H", "R", "S"],
- body=[["1", "30", "40", "35", "30"], ["2", "30", "40", "35", "30"]],
- first_col_heading=True,
- column_widths=[5, 5, 5, 5, 5],
- alignments=[Alignment.LEFT] + [Alignment.RIGHT] * 4,
+ header=["Product", "Category", "Price", "Rating"],
+ body=[
+ ["Milk", "Dairy", "$2.99", "6.283"],
+ ["Cheese", "Dairy", "$10.99", "8.2"],
+ ["Apples", "Produce", "$0.99", "10.00"],
+ ],
+ column_widths=[12, 12, 12, 12],
+ alignments=[Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.DECIMAL],
)
print(output)
"""
-╔═════╦═══════════════════════╗
-║ # ║ G H R S ║
-╟─────╫───────────────────────╢
-║ 1 ║ 30 40 35 30 ║
-║ 2 ║ 30 40 35 30 ║
-╚═════╩═══════════════════════╝
+╔═══════════════════════════════════════════════════╗
+║ Product Category Price Rating ║
+╟───────────────────────────────────────────────────╢
+║ Milk Dairy $2.99 6.283 ║
+║ Cheese Dairy $10.99 8.2 ║
+║ Apples Produce $0.99 10.00 ║
+╚═══════════════════════════════════════════════════╝
"""
```
@@ -119,7 +123,7 @@ output = table2ascii(
body=[["10", "30", "40", "35"], ["20", "10", "20", "5"]],
style=PresetStyle.plain,
cell_padding=0,
- alignments=[Alignment.LEFT] * 4,
+ alignments=Alignment.LEFT,
)
print(output)
@@ -199,18 +203,19 @@ All parameters are optional. At least one of `header`, `body`, and `footer` must
Refer to the [documentation](https://table2ascii.readthedocs.io/en/stable/api.html#table2ascii) for more information.
-| Option | Type | Default | Description |
-| :-----------------: | :----------------------------: | :-------------------: | :-------------------------------------------------------------------------------: |
-| `header` | `Sequence[SupportsStr]` | `None` | First table row seperated by header row separator. Values should support `str()` |
-| `body` | `Sequence[Sequence[Sequence]]` | `None` | 2D List of rows for the main section of the table. Values should support `str()` |
-| `footer` | `Sequence[Sequence]` | `None` | Last table row seperated by header row separator. Values should support `str()` |
-| `column_widths` | `Sequence[Optional[int]]` | `None` (automatic) | List of column widths in characters for each column |
-| `alignments` | `Sequence[Alignment]` | `None` (all centered) | Column alignments
(ex. `[Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT]`) |
-| `style` | `TableStyle` | `double_thin_compact` | Table style to use for the table\* |
-| `first_col_heading` | `bool` | `False` | Whether to add a heading column separator after the first column |
-| `last_col_heading` | `bool` | `False` | Whether to add a heading column separator before the last column |
-| `cell_padding` | `int` | `1` | The minimum number of spaces to add between the cell content and the cell border |
-| `use_wcwidth` | `bool` | `True` | Whether to use [wcwidth][wcwidth] instead of `len()` to calculate cell width |
+| Option | Supported Types | Description |
+| :-----------------: | :-----------------------------------------------------------------------------: | :--------------------------------------------------------------------------------------------------: |
+| `header` | `Sequence[SupportsStr]`, `None`
(Default: `None`) | First table row seperated by header row separator. Values should support `str()` |
+| `body` | `Sequence[Sequence[SupportsStr]]`, `None`
(Default: `None`) | 2D List of rows for the main section of the table. Values should support `str()` |
+| `footer` | `Sequence[SupportsStr]`, `None`
(Default: `None`) | Last table row seperated by header row separator. Values should support `str()` |
+| `column_widths` | `Sequence[Optional[int]]`, `None`
(Default: `None` / automatic) | List of column widths in characters for each column |
+| `alignments` | `Sequence[Alignment]`, `Alignment`, `None`
(Default: `None` / all centered) | Column alignments
(ex. `[Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.DECIMAL]`) |
+| `number_alignments` | `Sequence[Alignment]`, `Alignment`, `None`
(Default: `None`) | Column alignments for numeric values. `alignments` will be used if not specified. |
+| `style` | `TableStyle`
(Default: `double_thin_compact`) | Table style to use for the table\* |
+| `first_col_heading` | `bool`
(Default: `False`) | Whether to add a heading column separator after the first column |
+| `last_col_heading` | `bool`
(Default: `False`) | Whether to add a heading column separator before the last column |
+| `cell_padding` | `int`
(Default: `1`) | The minimum number of spaces to add between the cell content and the cell border |
+| `use_wcwidth` | `bool`
(Default: `True`) | Whether to use [wcwidth][wcwidth] instead of `len()` to calculate cell width |
[wcwidth]: https://pypi.org/project/wcwidth/
diff --git a/docs/source/_static/css/custom.css b/docs/source/_static/css/custom.css
index c0c2c34..a3475f8 100644
--- a/docs/source/_static/css/custom.css
+++ b/docs/source/_static/css/custom.css
@@ -18,4 +18,9 @@
/* Change code block font */
:root {
--pst-font-family-monospace: "Hack", "Source Code Pro", "SFMono-Regular", "Menlo", "Monaco", "Consolas", "Liberation Mono", "Courier New", "Courier", monospace;
+}
+
+/* Adjust margin on version directives within parameter lists */
+div.versionchanged p, div.versionadded p {
+ margin-bottom: 10px;
}
\ No newline at end of file
diff --git a/docs/source/usage.rst b/docs/source/usage.rst
index 70f8487..d1eb820 100644
--- a/docs/source/usage.rst
+++ b/docs/source/usage.rst
@@ -6,115 +6,119 @@ Convert lists to ASCII tables
.. code:: py
- from table2ascii import table2ascii
-
- output = table2ascii(
- header=["#", "G", "H", "R", "S"],
- body=[["1", "30", "40", "35", "30"], ["2", "30", "40", "35", "30"]],
- footer=["SUM", "130", "140", "135", "130"],
- )
-
- print(output)
-
- """
- ╔═════════════════════════════╗
- ║ # G H R S ║
- ╟─────────────────────────────╢
- ║ 1 30 40 35 30 ║
- ║ 2 30 40 35 30 ║
- ╟─────────────────────────────╢
- ║ SUM 130 140 135 130 ║
- ╚═════════════════════════════╝
- """
+ from table2ascii import table2ascii
+
+ output = table2ascii(
+ header=["#", "G", "H", "R", "S"],
+ body=[["1", "30", "40", "35", "30"], ["2", "30", "40", "35", "30"]],
+ footer=["SUM", "130", "140", "135", "130"],
+ )
+
+ print(output)
+
+ """
+ ╔═════════════════════════════╗
+ ║ # G H R S ║
+ ╟─────────────────────────────╢
+ ║ 1 30 40 35 30 ║
+ ║ 2 30 40 35 30 ║
+ ╟─────────────────────────────╢
+ ║ SUM 130 140 135 130 ║
+ ╚═════════════════════════════╝
+ """
Set first or last column headings
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code:: py
- from table2ascii import table2ascii
+ from table2ascii import table2ascii
- output = table2ascii(
- body=[["Assignment", "30", "40", "35", "30"], ["Bonus", "10", "20", "5", "10"]],
- first_col_heading=True,
- )
+ output = table2ascii(
+ body=[["Assignment", "30", "40", "35", "30"], ["Bonus", "10", "20", "5", "10"]],
+ first_col_heading=True,
+ )
- print(output)
+ print(output)
- """
- ╔════════════╦═══════════════════╗
- ║ Assignment ║ 30 40 35 30 ║
- ║ Bonus ║ 10 20 5 10 ║
- ╚════════════╩═══════════════════╝
- """
+ """
+ ╔════════════╦═══════════════════╗
+ ║ Assignment ║ 30 40 35 30 ║
+ ║ Bonus ║ 10 20 5 10 ║
+ ╚════════════╩═══════════════════╝
+ """
Set column widths and alignments
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. code:: py
- from table2ascii import table2ascii, Alignment
+ from table2ascii import table2ascii, Alignment
- output = table2ascii(
- header=["#", "G", "H", "R", "S"],
- body=[["1", "30", "40", "35", "30"], ["2", "30", "40", "35", "30"]],
- first_col_heading=True,
- column_widths=[5, 5, 5, 5, 5],
- alignments=[Alignment.LEFT] + [Alignment.RIGHT] * 4,
- )
+ output = table2ascii(
+ header=["Product", "Category", "Price", "Rating"],
+ body=[
+ ["Milk", "Dairy", "$2.99", "6.283"],
+ ["Cheese", "Dairy", "$10.99", "8.2"],
+ ["Apples", "Produce", "$0.99", "10.00"],
+ ],
+ column_widths=[12, 12, 12, 12],
+ alignments=[Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.DECIMAL],
+ )
- print(output)
+ print(output)
- """
- ╔═════╦═══════════════════════╗
- ║ # ║ G H R S ║
- ╟─────╫───────────────────────╢
- ║ 1 ║ 30 40 35 30 ║
- ║ 2 ║ 30 40 35 30 ║
- ╚═════╩═══════════════════════╝
- """
+ """
+ ╔═══════════════════════════════════════════════════╗
+ ║ Product Category Price Rating ║
+ ╟───────────────────────────────────────────────────╢
+ ║ Milk Dairy $2.99 6.283 ║
+ ║ Cheese Dairy $10.99 8.2 ║
+ ║ Apples Produce $0.99 10.00 ║
+ ╚═══════════════════════════════════════════════════╝
+ """
Use a preset style
~~~~~~~~~~~~~~~~~~
.. code:: py
- from table2ascii import table2ascii, Alignment, PresetStyle
-
- output = table2ascii(
- header=["First", "Second", "Third", "Fourth"],
- body=[["10", "30", "40", "35"], ["20", "10", "20", "5"]],
- column_widths=[10, 10, 10, 10],
- style=PresetStyle.ascii_box
- )
-
- print(output)
-
- """
- +----------+----------+----------+----------+
- | First | Second | Third | Fourth |
- +----------+----------+----------+----------+
- | 10 | 30 | 40 | 35 |
- +----------+----------+----------+----------+
- | 20 | 10 | 20 | 5 |
- +----------+----------+----------+----------+
- """
-
- output = table2ascii(
- header=["First", "Second", "Third", "Fourth"],
- body=[["10", "30", "40", "35"], ["20", "10", "20", "5"]],
- style=PresetStyle.plain,
- cell_padding=0,
- alignments=[Alignment.LEFT] * 4,
- )
-
- print(output)
-
- """
- First Second Third Fourth
- 10 30 40 35
- 20 10 20 5
- """
+ from table2ascii import table2ascii, Alignment, PresetStyle
+
+ output = table2ascii(
+ header=["First", "Second", "Third", "Fourth"],
+ body=[["10", "30", "40", "35"], ["20", "10", "20", "5"]],
+ column_widths=[10, 10, 10, 10],
+ style=PresetStyle.ascii_box
+ )
+
+ print(output)
+
+ """
+ +----------+----------+----------+----------+
+ | First | Second | Third | Fourth |
+ +----------+----------+----------+----------+
+ | 10 | 30 | 40 | 35 |
+ +----------+----------+----------+----------+
+ | 20 | 10 | 20 | 5 |
+ +----------+----------+----------+----------+
+ """
+
+ output = table2ascii(
+ header=["First", "Second", "Third", "Fourth"],
+ body=[["10", "30", "40", "35"], ["20", "10", "20", "5"]],
+ style=PresetStyle.plain,
+ cell_padding=0,
+ alignments=Alignment.LEFT,
+ )
+
+ print(output)
+
+ """
+ First Second Third Fourth
+ 10 30 40 35
+ 20 10 20 5
+ """
Define a custom style
~~~~~~~~~~~~~~~~~~~~~
@@ -123,27 +127,27 @@ Check :ref:`TableStyle` for more info.
.. code:: py
- from table2ascii import table2ascii, TableStyle
+ from table2ascii import table2ascii, TableStyle
- my_style = TableStyle.from_string("*-..*||:+-+:+ *''*")
+ my_style = TableStyle.from_string("*-..*||:+-+:+ *''*")
- output = table2ascii(
- header=["First", "Second", "Third"],
- body=[["10", "30", "40"], ["20", "10", "20"], ["30", "20", "30"]],
- style=my_style,
- )
+ output = table2ascii(
+ header=["First", "Second", "Third"],
+ body=[["10", "30", "40"], ["20", "10", "20"], ["30", "20", "30"]],
+ style=my_style,
+ )
- print(output)
+ print(output)
- """
- *-------.--------.-------*
- | First : Second : Third |
- +-------:--------:-------+
- | 10 : 30 : 40 |
- | 20 : 10 : 20 |
- | 30 : 20 : 30 |
- *-------'--------'-------*
- """
+ """
+ *-------.--------.-------*
+ | First : Second : Third |
+ +-------:--------:-------+
+ | 10 : 30 : 40 |
+ | 20 : 10 : 20 |
+ | 30 : 20 : 30 |
+ *-------'--------'-------*
+ """
Merge adjacent cells
~~~~~~~~~~~~~~~~~~~~
diff --git a/pyproject.toml b/pyproject.toml
index 9db538c..7b8a95a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,8 +5,8 @@ build-backend = "setuptools.build_meta"
[project]
name = "table2ascii"
+version = "1.1.0"
authors = [{name = "Jonah Lawrence", email = "jonah@freshidea.com"}]
-dynamic = ["version"]
description = "Convert 2D Python lists into Unicode/ASCII tables"
readme = "README.md"
requires-python = ">=3.7"
@@ -39,6 +39,7 @@ classifiers = [
]
dependencies = [
"typing-extensions>=3.7.4; python_version<'3.8'",
+ "importlib-metadata<5,>=1; python_version<'3.8'",
"wcwidth<1",
]
diff --git a/setup.py b/setup.py
index 5fbf35d..d32b777 100644
--- a/setup.py
+++ b/setup.py
@@ -1,16 +1,4 @@
# /usr/bin/env python
-import re
-
from setuptools import setup
-
-def version():
- version = ""
- with open("table2ascii/__init__.py") as f:
- version = re.search(r'^__version__\s*=\s*[\'"]([^\'"]*)[\'"]', f.read(), re.MULTILINE)
- if not version:
- raise RuntimeError("version is not set")
- return version.group(1)
-
-
-setup(name="table2ascii", version=version())
+setup()
diff --git a/table2ascii/__init__.py b/table2ascii/__init__.py
index 19fa31a..a86dc02 100644
--- a/table2ascii/__init__.py
+++ b/table2ascii/__init__.py
@@ -1,6 +1,8 @@
"""
table2ascii - Library for converting 2D Python lists to fancy ASCII/Unicode tables
"""
+import sys
+from typing import TYPE_CHECKING
from .alignment import Alignment
from .merge import Merge
@@ -8,7 +10,12 @@
from .table_style import TableStyle
from .table_to_ascii import table2ascii
-__version__ = "1.0.4"
+if TYPE_CHECKING or sys.version_info >= (3, 8):
+ from importlib import metadata
+else:
+ import importlib_metadata as metadata
+
+__version__ = metadata.version(__name__)
__all__ = [
"Alignment",
diff --git a/table2ascii/alignment.py b/table2ascii/alignment.py
index 0a8e5f7..f11fa39 100644
--- a/table2ascii/alignment.py
+++ b/table2ascii/alignment.py
@@ -4,31 +4,70 @@
class Alignment(IntEnum):
"""Enum for text alignment types within a table cell
- Example::
+ A list of alignment types can be used to align each column individually::
from table2ascii import Alignment, table2ascii
table2ascii(
- header=["Product", "Category", "Price", "In Stock"],
+ header=["Product", "Category", "Price", "Rating"],
body=[
- ["Milk", "Dairy", "$2.99", "Yes"],
- ["Cheese", "Dairy", "$10.99", "No"],
- ["Apples", "Produce", "$0.99", "Yes"],
+ ["Milk", "Dairy", "$2.99", "6.28318"],
+ ["Cheese", "Dairy", "$10.99", "8.2"],
+ ["Apples", "Produce", "$0.99", "10.00"],
],
- alignments=[Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.LEFT],
+ # Align the first column to the left, the second to the center,
+ # the third to the right, and the fourth to the decimal point
+ alignments=[Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.DECIMAL],
)
\"\"\"
╔════════════════════════════════════════╗
- ║ Product Category Price In Stock ║
+ ║ Product Category Price Rating ║
╟────────────────────────────────────────╢
- ║ Milk Dairy $2.99 Yes ║
- ║ Cheese Dairy $10.99 No ║
- ║ Apples Produce $0.99 Yes ║
+ ║ Milk Dairy $2.99 6.28318 ║
+ ║ Cheese Dairy $10.99 8.2 ║
+ ║ Apples Produce $0.99 10.00 ║
╚════════════════════════════════════════╝
\"\"\"
+
+ A single alignment type can be used to align all columns::
+
+ table2ascii(
+ header=["First Name", "Last Name", "Age"],
+ body=[
+ ["John", "Smith", 30],
+ ["Jane", "Doe", 28],
+ ],
+ alignments=Alignment.LEFT, # Align all columns to the left
+ number_alignments=Alignment.RIGHT, # Align all numeric values to the right
+ )
+
+ \"\"\"
+ ╔══════════════════════════════╗
+ ║ First Name Last Name Age ║
+ ╟──────────────────────────────╢
+ ║ John Smith 30 ║
+ ║ Jane Doe 28 ║
+ ╚══════════════════════════════╝
+ \"\"\"
+
+ .. note::
+
+ If :attr:`DECIMAL` is used in the ``number_alignments`` argument to :func:`table2ascii`,
+ all non-numeric values will be aligned according to the ``alignments`` argument.
+ If the :attr:`DECIMAL` alignment type is used in the ``alignments`` argument,
+ all non-numeric values will be aligned to the center.
+ Numeric values include integers, floats, and strings containing only :meth:`decimal `
+ characters and at most one decimal point.
+
+ .. versionchanged:: 1.1.0
+
+ Added :attr:`DECIMAL` alignment -- align decimal numbers such that
+ the decimal point is aligned with the decimal point of all other numbers
+ in the same column.
"""
LEFT = 0
CENTER = 1
RIGHT = 2
+ DECIMAL = 3
diff --git a/table2ascii/annotations.py b/table2ascii/annotations.py
index 241e787..60a4e3d 100644
--- a/table2ascii/annotations.py
+++ b/table2ascii/annotations.py
@@ -2,14 +2,11 @@
from abc import abstractmethod
from typing import TYPE_CHECKING
-if sys.version_info >= (3, 8):
+if TYPE_CHECKING or sys.version_info >= (3, 8):
from typing import Protocol, runtime_checkable
else:
from typing_extensions import Protocol, runtime_checkable
-if TYPE_CHECKING:
- from typing import Protocol
-
@runtime_checkable
class SupportsStr(Protocol):
diff --git a/table2ascii/options.py b/table2ascii/options.py
index 36f6ee0..28779c2 100644
--- a/table2ascii/options.py
+++ b/table2ascii/options.py
@@ -11,6 +11,10 @@
class Options:
"""Class for storing options that the user sets
+ .. versionchanged:: 1.1.0
+
+ Added ``number_alignments`` option
+
.. versionchanged:: 1.0.0
Added ``use_wcwidth`` option
@@ -19,7 +23,8 @@ class Options:
first_col_heading: bool
last_col_heading: bool
column_widths: Sequence[int | None] | None
- alignments: Sequence[Alignment] | None
+ alignments: Sequence[Alignment] | Alignment | None
+ number_alignments: Sequence[Alignment] | Alignment | None
cell_padding: int
style: TableStyle
use_wcwidth: bool
diff --git a/table2ascii/table_to_ascii.py b/table2ascii/table_to_ascii.py
index 8116467..c4359a8 100644
--- a/table2ascii/table_to_ascii.py
+++ b/table2ascii/table_to_ascii.py
@@ -67,14 +67,20 @@ def __init__(
if not header and not body and not footer:
raise NoHeaderBodyOrFooterError()
- # calculate or use given column widths
- self.__column_widths = self.__calculate_column_widths(options.column_widths)
+ self.__alignments = self.__determine_alignments(
+ options.alignments, default=Alignment.CENTER
+ )
+ self.__number_alignments = self.__determine_alignments(
+ options.number_alignments, default=self.__alignments
+ )
- self.__alignments = options.alignments or [Alignment.CENTER] * self.__columns
+ # keep track of the number widths and positions of the decimal points for decimal alignment
+ decimal_widths, decimal_positions = self.__calculate_decimal_widths_and_positions()
+ self.__decimal_widths: list[int] = decimal_widths
+ self.__decimal_positions: list[int] = decimal_positions
- # check if alignments specified have a different number of columns
- if options.alignments and len(options.alignments) != self.__columns:
- raise AlignmentCountMismatchError(options.alignments, self.__columns)
+ # calculate or use given column widths
+ self.__column_widths = self.__calculate_column_widths(options.column_widths)
# check if the cell padding is valid
if self.__cell_padding < 0:
@@ -97,6 +103,33 @@ def __count_columns(self) -> int:
return len(self.__body[0])
return 0
+ def __determine_alignments(
+ self,
+ user_alignments: Sequence[Alignment] | Alignment | None,
+ *,
+ default: Sequence[Alignment] | Alignment,
+ ) -> list[Alignment]:
+ """Determine the alignments for each column based on the user provided alignments option.
+
+ Args:
+ user_alignments: The alignments specified by the user
+ default: The default alignments to use if user_alignments is None
+
+ Returns:
+ The alignments for each column in the table
+ """
+ alignments = user_alignments if user_alignments is not None else default
+
+ # if alignments is a single Alignment, convert it to a list of that Alignment
+ if isinstance(alignments, Alignment):
+ alignments = [alignments] * self.__columns
+
+ # check if alignments specified have a different number of columns
+ if len(alignments) != self.__columns:
+ raise AlignmentCountMismatchError(alignments, self.__columns)
+
+ return list(alignments)
+
def __auto_column_widths(self) -> list[int]:
"""Get the minimum number of characters needed for the values in each column in the table
with 1 space of padding on each side.
@@ -125,10 +158,46 @@ def get_column_width(row: Sequence[SupportsStr], column: int) -> int:
header_size = get_column_width(self.__header, i) if self.__header else 0
body_size = max(get_column_width(row, i) for row in self.__body) if self.__body else 0
footer_size = get_column_width(self.__footer, i) if self.__footer else 0
+ min_text_width = max(header_size, body_size, footer_size, self.__decimal_widths[i])
# get the max and add 2 for padding each side with a space depending on cell padding
- column_widths.append(max(header_size, body_size, footer_size) + self.__cell_padding * 2)
+ column_widths.append(min_text_width + self.__cell_padding * 2)
return column_widths
+ def __calculate_decimal_widths_and_positions(self) -> tuple[list[int], list[int]]:
+ """Calculate the positions of the decimal points for decimal alignment.
+
+ Returns:
+ A tuple of the widths of the decimal numbers in each column
+ and the positions of the decimal points in each column
+ """
+ decimal_widths: list[int] = [0] * self.__columns
+ decimal_positions: list[int] = [0] * self.__columns
+ for i in range(self.__columns):
+ # skip if the column is not decimal aligned
+ if self.__number_alignments[i] != Alignment.DECIMAL:
+ continue
+ # list all values in the i-th column of header, body, and footer
+ values = [str(self.__header[i])] if self.__header else []
+ values += [str(row[i]) for row in self.__body] if self.__body else []
+ values += [str(self.__footer[i])] if self.__footer else []
+ # filter out values that are not numbers and split at the decimal point
+ split_values = [
+ self.__split_decimal(value) for value in values if self.__is_number(value)
+ ]
+ # skip if there are no decimal values
+ if len(split_values) == 0:
+ continue
+ # get the max number of digits before and after the decimal point
+ max_before_decimal = max(self.__str_width(parts[0]) for parts in split_values)
+ max_after_decimal = max(self.__str_width(parts[1]) for parts in split_values)
+ # add 1 for the decimal point if there are any decimal point values
+ has_decimal = any(self.__is_number(value) and "." in value for value in values)
+ # store the total width of the decimal numbers in the column
+ decimal_widths[i] = max_before_decimal + max_after_decimal + int(has_decimal)
+ # store the max digits before the decimal point for decimal alignment
+ decimal_positions[i] = max_before_decimal
+ return decimal_widths, decimal_positions
+
def __calculate_column_widths(
self, user_column_widths: Sequence[int | None] | None
) -> list[int]:
@@ -169,30 +238,47 @@ def __fix_rows_beginning_with_merge(self) -> None:
if self.__footer and self.__footer[0] == Merge.LEFT:
self.__footer[0] = ""
- def __pad(self, cell_value: SupportsStr, width: int, alignment: Alignment) -> str:
+ def __pad(self, cell_value: SupportsStr, width: int, col_index: int) -> str:
"""Pad a string of text to a given width with specified alignment
Args:
cell_value: The text in the cell to pad
width: The width in characters to pad to
- alignment: The alignment to use
+ col_index: The index of the column
Returns:
The padded text
"""
+ alignment = self.__alignments[col_index]
text = str(cell_value)
+ # set alignment for numeric values
+ if self.__is_number(text):
+ # if the number alignment is decimal, pad such that the decimal point
+ # is aligned to the column's decimal position and use the default alignment
+ if self.__number_alignments[col_index] == Alignment.DECIMAL:
+ decimal_position = self.__decimal_positions[col_index]
+ decimal_max_width = self.__decimal_widths[col_index]
+ text_before_decimal = self.__split_decimal(text)[0]
+ before = " " * (decimal_position - self.__str_width(text_before_decimal))
+ after = " " * (decimal_max_width - self.__str_width(text) - len(before))
+ text = f"{before}{text}{after}"
+ # otherwise use the number alignment as the alignment for the cell
+ else:
+ alignment = self.__number_alignments[col_index]
+ # add minimum cell padding around the text
padding = " " * self.__cell_padding
padded_text = f"{padding}{text}{padding}"
text_width = self.__str_width(padded_text)
+ # pad the text based on the alignment
if alignment == Alignment.LEFT:
# pad with spaces on the end
return padded_text + (" " * (width - text_width))
- if alignment == Alignment.CENTER:
+ elif alignment in (Alignment.CENTER, Alignment.DECIMAL):
# pad with spaces, half on each side
before = " " * floor((width - text_width) / 2)
after = " " * ceil((width - text_width) / 2)
return before + padded_text + after
- if alignment == Alignment.RIGHT:
+ elif alignment == Alignment.RIGHT:
# pad with spaces at the beginning
return (" " * (width - text_width)) + padded_text
raise InvalidAlignmentError(alignment)
@@ -403,7 +489,7 @@ def __get_padded_cell_line_content(
return self.__pad(
cell_value=col_content,
width=pad_width,
- alignment=self.__alignments[col_index],
+ col_index=col_index,
)
def __top_edge_to_ascii(self) -> str:
@@ -530,6 +616,19 @@ def __str_width(self, text: str) -> int:
# if use_wcwidth is False or wcswidth fails, fall back to len
return width if width >= 0 else len(text)
+ @staticmethod
+ def __is_number(text: str) -> bool:
+ """Returns True if the string is a number, with or without a decimal point"""
+ return text.replace(".", "", 1).isdecimal()
+
+ @staticmethod
+ def __split_decimal(text: str) -> tuple[str, str]:
+ """Splits a string into a tuple of the integer and decimal parts"""
+ if "." in text:
+ before, after = text.split(".", 1)
+ return before, after
+ return text, ""
+
def to_ascii(self) -> str:
"""Generates a formatted ASCII table
@@ -569,16 +668,14 @@ def table2ascii(
first_col_heading: bool = False,
last_col_heading: bool = False,
column_widths: Sequence[int | None] | None = None,
- alignments: Sequence[Alignment] | None = None,
+ alignments: Sequence[Alignment] | Alignment | None = None,
+ number_alignments: Sequence[Alignment] | Alignment | None = None,
cell_padding: int = 1,
style: TableStyle = PresetStyle.double_thin_compact,
use_wcwidth: bool = True,
) -> str:
"""Convert a 2D Python table to ASCII text
- .. versionchanged:: 1.0.0
- Added the ``use_wcwidth`` parameter defaulting to :py:obj:`True`.
-
Args:
header: List of column values in the table's header row. All values should be :class:`str`
or support :class:`str` conversion. If not specified, the table will not have a header row.
@@ -595,8 +692,21 @@ def table2ascii(
is passed instead of a :class:`~collections.abc.Sequence`, all columns will be automatically
sized. Defaults to :py:obj:`None`.
alignments: List of alignments for each column
- (ex. ``[Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT]``). If not specified or set to
- :py:obj:`None`, all columns will be center-aligned. Defaults to :py:obj:`None`.
+ (ex. ``[Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT, Alignment.DECIMAL]``)
+ or a single alignment to apply to all columns (ex. ``Alignment.LEFT``).
+ If not specified or set to :py:obj:`None`, all columns will be center-aligned.
+ Defaults to :py:obj:`None`.
+
+ .. versionchanged:: 1.1.0
+ ``alignments`` can now also be specified as a single :class:`Alignment` value to apply to all columns.
+ number_alignments: List of alignments for numeric values in each column or a single alignment
+ to apply to all columns. This argument can be used to override the alignment of numbers and
+ is ignored for non-numeric values. Numeric values include integers, floats, and strings containing only
+ :meth:`decimal ` characters and at most one decimal point.
+ If not specified or set to :py:obj:`None`, numbers will be aligned based on the ``alignments`` argument.
+ Defaults to :py:obj:`None`.
+
+ .. versionadded:: 1.1.0
cell_padding: The minimum number of spaces to add between the cell content and the column
separator. If set to ``0``, the cell content will be flush against the column separator.
Defaults to ``1``.
@@ -608,6 +718,8 @@ def table2ascii(
zero-width space, etc.), whereas :func:`len` determines the width solely based on the number of
characters in the string. Defaults to :py:obj:`True`.
+ .. versionadded:: 1.0.0
+
Returns:
The generated ASCII table
"""
@@ -620,6 +732,7 @@ def table2ascii(
last_col_heading=last_col_heading,
column_widths=column_widths,
alignments=alignments,
+ number_alignments=number_alignments,
cell_padding=cell_padding,
style=style,
use_wcwidth=use_wcwidth,
diff --git a/tests/test_alignments.py b/tests/test_alignments.py
index 3e8b874..67aa5d3 100644
--- a/tests/test_alignments.py
+++ b/tests/test_alignments.py
@@ -1,6 +1,6 @@
import pytest
-from table2ascii import Alignment, table2ascii as t2a
+from table2ascii import Alignment, PresetStyle, table2ascii as t2a
from table2ascii.exceptions import AlignmentCountMismatchError, InvalidAlignmentError
@@ -25,7 +25,7 @@ def test_first_left_four_right():
assert text == expected
-def test_wrong_number_alignments():
+def test_wrong_number_of_alignments():
with pytest.raises(AlignmentCountMismatchError):
t2a(
header=["#", "G", "H", "R", "S"],
@@ -97,3 +97,96 @@ def test_alignments_multiline_data():
"╚═══════════════════════════════════════════╝"
)
assert text == expected
+
+
+def test_decimal_alignment():
+ text = t2a(
+ header=["1.1.1", "G", "Long Header", "H", "R", "3.8"],
+ body=[[100.00001, 2, 3.14, 33, "AB", "1.5"], [10.0001, 22.0, 2.718, 3, "CD", "3.03"]],
+ footer=[10000.01, "123", 10.0, 0, "E", "A"],
+ alignments=[Alignment.DECIMAL] * 6,
+ first_col_heading=True,
+ style=PresetStyle.double_thin_box,
+ )
+ expected = (
+ "╔═════════════╦═══════╤═════════════╤════╤════╤═════════╗\n"
+ "║ 1.1.1 ║ G │ Long Header │ H │ R │ 3.8 ║\n"
+ "╠═════════════╬═══════╪═════════════╪════╪════╪═════════╣\n"
+ "║ 100.00001 ║ 2 │ 3.14 │ 33 │ AB │ 1.5 ║\n"
+ "╟─────────────╫───────┼─────────────┼────┼────┼─────────╢\n"
+ "║ 10.0001 ║ 22.0 │ 2.718 │ 3 │ CD │ 3.03 ║\n"
+ "╠═════════════╬═══════╪═════════════╪════╪════╪═════════╣\n"
+ "║ 10000.01 ║ 123 │ 10.0 │ 0 │ E │ A ║\n"
+ "╚═════════════╩═══════╧═════════════╧════╧════╧═════════╝"
+ )
+ assert text == expected
+
+
+def test_single_decimal_alignment():
+ text = t2a(
+ header=["1.1.1", "G", "Long Header"],
+ body=[[100.00001, 2, 3.14], [10.0001, 22.0, 2.718]],
+ alignments=Alignment.DECIMAL,
+ )
+ expected = (
+ "╔════════════════════════════════╗\n"
+ "║ 1.1.1 G Long Header ║\n"
+ "╟────────────────────────────────╢\n"
+ "║ 100.00001 2 3.14 ║\n"
+ "║ 10.0001 22.0 2.718 ║\n"
+ "╚════════════════════════════════╝"
+ )
+ assert text == expected
+
+
+def test_single_left_alignment():
+ text = t2a(
+ header=["1.1.1", "G", "Long Header"],
+ body=[[100.00001, 2, 3.14], [10.0001, 22.0, 2.718]],
+ alignments=Alignment.LEFT,
+ )
+ expected = (
+ "╔════════════════════════════════╗\n"
+ "║ 1.1.1 G Long Header ║\n"
+ "╟────────────────────────────────╢\n"
+ "║ 100.00001 2 3.14 ║\n"
+ "║ 10.0001 22.0 2.718 ║\n"
+ "╚════════════════════════════════╝"
+ )
+ assert text == expected
+
+
+def test_number_alignments():
+ text = t2a(
+ header=["1.1.1", "G", "Long Header", "Another Long Header"],
+ body=[[100.00001, 2, 3.14, 6.28], [10.0001, 22.0, 2.718, 1.618]],
+ alignments=[Alignment.LEFT, Alignment.RIGHT, Alignment.CENTER, Alignment.RIGHT],
+ number_alignments=[Alignment.DECIMAL, Alignment.LEFT, Alignment.RIGHT, Alignment.DECIMAL],
+ )
+ expected = (
+ "╔══════════════════════════════════════════════════════╗\n"
+ "║ 1.1.1 G Long Header Another Long Header ║\n"
+ "╟──────────────────────────────────────────────────────╢\n"
+ "║ 100.00001 2 3.14 6.28 ║\n"
+ "║ 10.0001 22.0 2.718 1.618 ║\n"
+ "╚══════════════════════════════════════════════════════╝"
+ )
+ assert text == expected
+
+
+def test_single_number_alignments():
+ text = t2a(
+ header=["1.1.1", "G", "Long Header", "S"],
+ body=[[100.00001, 2, 3.14, 6.28], [10.0001, 22.0, 2.718, 1.618]],
+ alignments=[Alignment.LEFT, Alignment.CENTER, Alignment.CENTER, Alignment.RIGHT],
+ number_alignments=Alignment.RIGHT,
+ )
+ expected = (
+ "╔════════════════════════════════════════╗\n"
+ "║ 1.1.1 G Long Header S ║\n"
+ "╟────────────────────────────────────────╢\n"
+ "║ 100.00001 2 3.14 6.28 ║\n"
+ "║ 10.0001 22.0 2.718 1.618 ║\n"
+ "╚════════════════════════════════════════╝"
+ )
+ assert text == expected