Skip to content

Commit 1a9d24d

Browse files
authored
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

Lines changed: 5 additions & 0 deletions
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

Lines changed: 11 additions & 9 deletions
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

Lines changed: 5 additions & 0 deletions
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

Lines changed: 62 additions & 26 deletions
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

Lines changed: 37 additions & 1 deletion
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy