Skip to content

Commit 1a9d24d

Browse files
authoredDec 29, 2022
feat: Support for aligning numbers separately from strings (#92)
1 parent dfbca8c commit 1a9d24d

File tree

5 files changed

+120
-36
lines changed

5 files changed

+120
-36
lines changed
 

‎docs/source/_static/css/custom.css

+5
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,9 @@
1818
/* Change code block font */
1919
:root {
2020
--pst-font-family-monospace: "Hack", "Source Code Pro", "SFMono-Regular", "Menlo", "Monaco", "Consolas", "Liberation Mono", "Courier New", "Courier", monospace;
21+
}
22+
23+
/* Adjust margin on version directives within parameter lists */
24+
div.versionchanged p, div.versionadded p {
25+
margin-bottom: 10px;
2126
}

‎table2ascii/alignment.py

+11-9
Original file line numberDiff line numberDiff line change
@@ -30,33 +30,35 @@ class Alignment(IntEnum):
3030
╚════════════════════════════════════════╝
3131
\"\"\"
3232
33-
A single alignment type can be used for all columns::
33+
A single alignment type can be used to align all columns::
3434
3535
table2ascii(
3636
header=["First Name", "Last Name", "Age"],
3737
body=[
3838
["John", "Smith", 30],
3939
["Jane", "Doe", 28],
4040
],
41-
# Align all columns to the left
42-
alignments=Alignment.LEFT,
41+
alignments=Alignment.LEFT, # Align all columns to the left
42+
number_alignments=Alignment.RIGHT, # Align all numeric values to the right
4343
)
4444
4545
\"\"\"
4646
╔══════════════════════════════╗
4747
║ First Name Last Name Age ║
4848
╟──────────────────────────────╢
49-
║ John Smith 30
50-
║ Jane Doe 28
49+
║ John Smith 30
50+
║ Jane Doe 28
5151
╚══════════════════════════════╝
5252
\"\"\"
5353
5454
.. note::
5555
56-
If the :attr:`DECIMAL` alignment type is used, any cell values that are
57-
not valid decimal numbers will be aligned to the center. Decimal numbers
58-
include integers, floats, and strings containing only
59-
:meth:`decimal <str.isdecimal>` characters and at most one decimal point.
56+
If :attr:`DECIMAL` is used in the ``number_alignments`` argument to :func:`table2ascii`,
57+
all non-numeric values will be aligned according to the ``alignments`` argument.
58+
If the :attr:`DECIMAL` alignment type is used in the ``alignments`` argument,
59+
all non-numeric values will be aligned to the center.
60+
Numeric values include integers, floats, and strings containing only :meth:`decimal <str.isdecimal>`
61+
characters and at most one decimal point.
6062
6163
.. versionchanged:: 1.1.0
6264

‎table2ascii/options.py

+5
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@
1111
class Options:
1212
"""Class for storing options that the user sets
1313
14+
.. versionchanged:: 1.1.0
15+
16+
Added ``number_alignments`` option
17+
1418
.. versionchanged:: 1.0.0
1519
1620
Added ``use_wcwidth`` option
@@ -20,6 +24,7 @@ class Options:
2024
last_col_heading: bool
2125
column_widths: Sequence[int | None] | None
2226
alignments: Sequence[Alignment] | Alignment | None
27+
number_alignments: Sequence[Alignment] | Alignment | None
2328
cell_padding: int
2429
style: TableStyle
2530
use_wcwidth: bool

‎table2ascii/table_to_ascii.py

+62-26
Original file line numberDiff line numberDiff line change
@@ -67,16 +67,12 @@ def __init__(
6767
if not header and not body and not footer:
6868
raise NoHeaderBodyOrFooterError()
6969

70-
alignments = options.alignments if options.alignments is not None else Alignment.CENTER
71-
72-
# if alignments is a single Alignment, convert it to a list of that Alignment
73-
self.__alignments: list[Alignment] = (
74-
[alignments] * self.__columns if isinstance(alignments, Alignment) else list(alignments)
70+
self.__alignments = self.__determine_alignments(
71+
options.alignments, default=Alignment.CENTER
72+
)
73+
self.__number_alignments = self.__determine_alignments(
74+
options.number_alignments, default=self.__alignments
7575
)
76-
77-
# check if alignments specified have a different number of columns
78-
if len(self.__alignments) != self.__columns:
79-
raise AlignmentCountMismatchError(self.__alignments, self.__columns)
8076

8177
# keep track of the number widths and positions of the decimal points for decimal alignment
8278
decimal_widths, decimal_positions = self.__calculate_decimal_widths_and_positions()
@@ -107,6 +103,33 @@ def __count_columns(self) -> int:
107103
return len(self.__body[0])
108104
return 0
109105

106+
def __determine_alignments(
107+
self,
108+
user_alignments: Sequence[Alignment] | Alignment | None,
109+
*,
110+
default: Sequence[Alignment] | Alignment,
111+
) -> list[Alignment]:
112+
"""Determine the alignments for each column based on the user provided alignments option.
113+
114+
Args:
115+
user_alignments: The alignments specified by the user
116+
default: The default alignments to use if user_alignments is None
117+
118+
Returns:
119+
The alignments for each column in the table
120+
"""
121+
alignments = user_alignments if user_alignments is not None else default
122+
123+
# if alignments is a single Alignment, convert it to a list of that Alignment
124+
if isinstance(alignments, Alignment):
125+
alignments = [alignments] * self.__columns
126+
127+
# check if alignments specified have a different number of columns
128+
if len(alignments) != self.__columns:
129+
raise AlignmentCountMismatchError(alignments, self.__columns)
130+
131+
return list(alignments)
132+
110133
def __auto_column_widths(self) -> list[int]:
111134
"""Get the minimum number of characters needed for the values in each column in the table
112135
with 1 space of padding on each side.
@@ -150,7 +173,8 @@ def __calculate_decimal_widths_and_positions(self) -> tuple[list[int], list[int]
150173
decimal_widths: list[int] = [0] * self.__columns
151174
decimal_positions: list[int] = [0] * self.__columns
152175
for i in range(self.__columns):
153-
if self.__alignments[i] != Alignment.DECIMAL:
176+
# skip if the column is not decimal aligned
177+
if self.__number_alignments[i] != Alignment.DECIMAL:
154178
continue
155179
# list all values in the i-th column of header, body, and footer
156180
values = [str(self.__header[i])] if self.__header else []
@@ -227,15 +251,20 @@ def __pad(self, cell_value: SupportsStr, width: int, col_index: int) -> str:
227251
"""
228252
alignment = self.__alignments[col_index]
229253
text = str(cell_value)
230-
# if using decimal alignment, pad such that the decimal point
231-
# is aligned to the column's decimal position
232-
if alignment == Alignment.DECIMAL and self.__is_number(text):
233-
decimal_position = self.__decimal_positions[col_index]
234-
decimal_max_width = self.__decimal_widths[col_index]
235-
text_before_decimal = self.__split_decimal(text)[0]
236-
before = " " * (decimal_position - self.__str_width(text_before_decimal))
237-
after = " " * (decimal_max_width - self.__str_width(text) - len(before))
238-
text = f"{before}{text}{after}"
254+
# set alignment for numeric values
255+
if self.__is_number(text):
256+
# if the number alignment is decimal, pad such that the decimal point
257+
# is aligned to the column's decimal position and use the default alignment
258+
if self.__number_alignments[col_index] == Alignment.DECIMAL:
259+
decimal_position = self.__decimal_positions[col_index]
260+
decimal_max_width = self.__decimal_widths[col_index]
261+
text_before_decimal = self.__split_decimal(text)[0]
262+
before = " " * (decimal_position - self.__str_width(text_before_decimal))
263+
after = " " * (decimal_max_width - self.__str_width(text) - len(before))
264+
text = f"{before}{text}{after}"
265+
# otherwise use the number alignment as the alignment for the cell
266+
else:
267+
alignment = self.__number_alignments[col_index]
239268
# add minimum cell padding around the text
240269
padding = " " * self.__cell_padding
241270
padded_text = f"{padding}{text}{padding}"
@@ -640,6 +669,7 @@ def table2ascii(
640669
last_col_heading: bool = False,
641670
column_widths: Sequence[int | None] | None = None,
642671
alignments: Sequence[Alignment] | Alignment | None = None,
672+
number_alignments: Sequence[Alignment] | Alignment | None = None,
643673
cell_padding: int = 1,
644674
style: TableStyle = PresetStyle.double_thin_compact,
645675
use_wcwidth: bool = True,
@@ -666,6 +696,17 @@ def table2ascii(
666696
or a single alignment to apply to all columns (ex. ``Alignment.LEFT``).
667697
If not specified or set to :py:obj:`None`, all columns will be center-aligned.
668698
Defaults to :py:obj:`None`.
699+
700+
.. versionchanged:: 1.1.0
701+
``alignments`` can now also be specified as a single :class:`Alignment` value to apply to all columns.
702+
number_alignments: List of alignments for numeric values in each column or a single alignment
703+
to apply to all columns. This argument can be used to override the alignment of numbers and
704+
is ignored for non-numeric values. Numeric values include integers, floats, and strings containing only
705+
:meth:`decimal <str.isdecimal>` characters and at most one decimal point.
706+
If not specified or set to :py:obj:`None`, numbers will be aligned based on the ``alignments`` argument.
707+
Defaults to :py:obj:`None`.
708+
709+
.. versionadded:: 1.1.0
669710
cell_padding: The minimum number of spaces to add between the cell content and the column
670711
separator. If set to ``0``, the cell content will be flush against the column separator.
671712
Defaults to ``1``.
@@ -677,13 +718,7 @@ def table2ascii(
677718
zero-width space, etc.), whereas :func:`len` determines the width solely based on the number of
678719
characters in the string. Defaults to :py:obj:`True`.
679720
680-
.. versionchanged:: 1.1.0
681-
682-
``alignments`` can now also be specified as a single :class:`Alignment` value to apply to all columns.
683-
684-
.. versionchanged:: 1.0.0
685-
686-
Added the ``use_wcwidth`` parameter defaulting to :py:obj:`True`.
721+
.. versionadded:: 1.0.0
687722
688723
Returns:
689724
The generated ASCII table
@@ -697,6 +732,7 @@ def table2ascii(
697732
last_col_heading=last_col_heading,
698733
column_widths=column_widths,
699734
alignments=alignments,
735+
number_alignments=number_alignments,
700736
cell_padding=cell_padding,
701737
style=style,
702738
use_wcwidth=use_wcwidth,

‎tests/test_alignments.py

+37-1
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ def test_first_left_four_right():
2525
assert text == expected
2626

2727

28-
def test_wrong_number_alignments():
28+
def test_wrong_number_of_alignments():
2929
with pytest.raises(AlignmentCountMismatchError):
3030
t2a(
3131
header=["#", "G", "H", "R", "S"],
@@ -154,3 +154,39 @@ def test_single_left_alignment():
154154
"╚════════════════════════════════╝"
155155
)
156156
assert text == expected
157+
158+
159+
def test_number_alignments():
160+
text = t2a(
161+
header=["1.1.1", "G", "Long Header", "Another Long Header"],
162+
body=[[100.00001, 2, 3.14, 6.28], [10.0001, 22.0, 2.718, 1.618]],
163+
alignments=[Alignment.LEFT, Alignment.RIGHT, Alignment.CENTER, Alignment.RIGHT],
164+
number_alignments=[Alignment.DECIMAL, Alignment.LEFT, Alignment.RIGHT, Alignment.DECIMAL],
165+
)
166+
expected = (
167+
"╔══════════════════════════════════════════════════════╗\n"
168+
"║ 1.1.1 G Long Header Another Long Header ║\n"
169+
"╟──────────────────────────────────────────────────────╢\n"
170+
"║ 100.00001 2 3.14 6.28 ║\n"
171+
"║ 10.0001 22.0 2.718 1.618 ║\n"
172+
"╚══════════════════════════════════════════════════════╝"
173+
)
174+
assert text == expected
175+
176+
177+
def test_single_number_alignments():
178+
text = t2a(
179+
header=["1.1.1", "G", "Long Header", "S"],
180+
body=[[100.00001, 2, 3.14, 6.28], [10.0001, 22.0, 2.718, 1.618]],
181+
alignments=[Alignment.LEFT, Alignment.CENTER, Alignment.CENTER, Alignment.RIGHT],
182+
number_alignments=Alignment.RIGHT,
183+
)
184+
expected = (
185+
"╔════════════════════════════════════════╗\n"
186+
"║ 1.1.1 G Long Header S ║\n"
187+
"╟────────────────────────────────────────╢\n"
188+
"║ 100.00001 2 3.14 6.28 ║\n"
189+
"║ 10.0001 22.0 2.718 1.618 ║\n"
190+
"╚════════════════════════════════════════╝"
191+
)
192+
assert text == expected

0 commit comments

Comments
 (0)
Please sign in to comment.