diff --git a/README.md b/README.md
index ffc0261..de9e288 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 ║
+╚═══════════════════════════════════════════════════╝
"""
```
@@ -199,18 +203,18 @@ 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 | 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, Alignment.DECIMAL]`) |
+| `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 |
[wcwidth]: https://pypi.org/project/wcwidth/
diff --git a/docs/source/usage.rst b/docs/source/usage.rst
index 70f8487..823c23e 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] * 4,
+ )
+
+ 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/table2ascii/alignment.py b/table2ascii/alignment.py
index 0a8e5f7..2ae487f 100644
--- a/table2ascii/alignment.py
+++ b/table2ascii/alignment.py
@@ -9,26 +9,40 @@ class Alignment(IntEnum):
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],
+ 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 ║
╚════════════════════════════════════════╝
\"\"\"
+
+ .. note::
+
+ If the :attr:`DECIMAL` alignment type is used, any cell values that are
+ not valid decimal numbers will be aligned to the center. Decimal numbers
+ 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/table_to_ascii.py b/table2ascii/table_to_ascii.py
index 8116467..0510bd1 100644
--- a/table2ascii/table_to_ascii.py
+++ b/table2ascii/table_to_ascii.py
@@ -67,15 +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 = options.alignments or [Alignment.CENTER] * self.__columns
# 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)
+ # 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
+
+ # 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:
raise InvalidCellPaddingError(self.__cell_padding)
@@ -125,10 +130,45 @@ 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):
+ if self.__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 +209,42 @@ 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)
+ # if using decimal alignment, pad such that the decimal point
+ # is aligned to the column's decimal position
+ if alignment == Alignment.DECIMAL and self.__is_number(text):
+ 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}"
+ # 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 +455,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 +582,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
diff --git a/tests/test_alignments.py b/tests/test_alignments.py
index 3e8b874..107c32b 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
@@ -97,3 +97,26 @@ 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