Skip to content

feat: Support for aligning numbers separately from strings #92

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Dec 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/source/_static/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
20 changes: 11 additions & 9 deletions table2ascii/alignment.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,33 +30,35 @@ class Alignment(IntEnum):
╚════════════════════════════════════════╝
\"\"\"

A single alignment type can be used for all columns::
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],
],
# Align all columns to the left
alignments=Alignment.LEFT,
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
║ John Smith 30
║ Jane Doe 28
╚══════════════════════════════╝
\"\"\"

.. 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 <str.isdecimal>` characters and at most one decimal point.
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 <str.isdecimal>`
characters and at most one decimal point.

.. versionchanged:: 1.1.0

Expand Down
5 changes: 5 additions & 0 deletions table2ascii/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -20,6 +24,7 @@ class Options:
last_col_heading: bool
column_widths: Sequence[int | None] | None
alignments: Sequence[Alignment] | Alignment | None
number_alignments: Sequence[Alignment] | Alignment | None
cell_padding: int
style: TableStyle
use_wcwidth: bool
88 changes: 62 additions & 26 deletions table2ascii/table_to_ascii.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,12 @@ def __init__(
if not header and not body and not footer:
raise NoHeaderBodyOrFooterError()

alignments = options.alignments if options.alignments is not None else Alignment.CENTER

# if alignments is a single Alignment, convert it to a list of that Alignment
self.__alignments: list[Alignment] = (
[alignments] * self.__columns if isinstance(alignments, Alignment) else list(alignments)
self.__alignments = self.__determine_alignments(
options.alignments, default=Alignment.CENTER
)
self.__number_alignments = self.__determine_alignments(
options.number_alignments, default=self.__alignments
)

# check if alignments specified have a different number of columns
if len(self.__alignments) != self.__columns:
raise AlignmentCountMismatchError(self.__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()
Expand Down Expand Up @@ -107,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.
Expand Down Expand Up @@ -150,7 +173,8 @@ def __calculate_decimal_widths_and_positions(self) -> tuple[list[int], list[int]
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:
# 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 []
Expand Down Expand Up @@ -227,15 +251,20 @@ def __pad(self, cell_value: SupportsStr, width: int, col_index: int) -> str:
"""
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}"
# 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}"
Expand Down Expand Up @@ -640,6 +669,7 @@ def table2ascii(
last_col_heading: bool = False,
column_widths: Sequence[int | None] | 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,
Expand All @@ -666,6 +696,17 @@ def table2ascii(
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 <str.isdecimal>` 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``.
Expand All @@ -677,13 +718,7 @@ 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`.

.. versionchanged:: 1.1.0

``alignments`` can now also be specified as a single :class:`Alignment` value to apply to all columns.

.. versionchanged:: 1.0.0

Added the ``use_wcwidth`` parameter defaulting to :py:obj:`True`.
.. versionadded:: 1.0.0

Returns:
The generated ASCII table
Expand All @@ -697,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,
Expand Down
38 changes: 37 additions & 1 deletion tests/test_alignments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down Expand Up @@ -154,3 +154,39 @@ def test_single_left_alignment():
"╚════════════════════════════════╝"
)
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