From 916c79dddc99ec86aa20b72e159ccad1e99f90f7 Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Wed, 28 Dec 2022 16:48:12 -0700 Subject: [PATCH 1/5] feat: Support for aligning numbers separately from strings --- docs/source/_static/css/custom.css | 5 ++ table2ascii/alignment.py | 10 ++-- table2ascii/options.py | 5 ++ table2ascii/table_to_ascii.py | 83 ++++++++++++++++++++---------- tests/test_alignments.py | 20 ++++++- 5 files changed, 92 insertions(+), 31 deletions(-) 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/table2ascii/alignment.py b/table2ascii/alignment.py index 041d167..e6d4070 100644 --- a/table2ascii/alignment.py +++ b/table2ascii/alignment.py @@ -53,10 +53,12 @@ class Alignment(IntEnum): .. 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. + 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 diff --git a/table2ascii/options.py b/table2ascii/options.py index e88bd44..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 @@ -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 diff --git a/table2ascii/table_to_ascii.py b/table2ascii/table_to_ascii.py index 46603d7..8263008 100644 --- a/table2ascii/table_to_ascii.py +++ b/table2ascii/table_to_ascii.py @@ -67,17 +67,11 @@ 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.__calculate_alignments(options.alignments, Alignment.CENTER) + self.__number_alignments = self.__calculate_alignments( + options.number_alignments, 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() self.__decimal_widths: list[int] = decimal_widths @@ -107,6 +101,32 @@ def __count_columns(self) -> int: return len(self.__body[0]) return 0 + def __calculate_alignments( + self, + user_alignments: Sequence[Alignment] | Alignment | None, + default_alignments: 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_alignments: 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_alignments + + # 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. @@ -150,7 +170,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 [] @@ -227,15 +248,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}" @@ -640,6 +666,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, @@ -666,6 +693,15 @@ 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. 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``. @@ -677,13 +713,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 @@ -697,6 +727,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 ff684e1..8edfa33 100644 --- a/tests/test_alignments.py +++ b/tests/test_alignments.py @@ -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"], @@ -154,3 +154,21 @@ 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 From 426a03cab05c0ced2b816f51d81510ba8b7a8f2b Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Wed, 28 Dec 2022 16:54:53 -0700 Subject: [PATCH 2/5] refactor __determine_alignments --- table2ascii/table_to_ascii.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/table2ascii/table_to_ascii.py b/table2ascii/table_to_ascii.py index 8263008..c84ad7d 100644 --- a/table2ascii/table_to_ascii.py +++ b/table2ascii/table_to_ascii.py @@ -67,9 +67,11 @@ def __init__( if not header and not body and not footer: raise NoHeaderBodyOrFooterError() - self.__alignments = self.__calculate_alignments(options.alignments, Alignment.CENTER) - self.__number_alignments = self.__calculate_alignments( - options.number_alignments, self.__alignments + self.__alignments = self.__determine_alignments( + options.alignments, default=Alignment.CENTER + ) + self.__number_alignments = self.__determine_alignments( + options.number_alignments, default=self.__alignments ) # keep track of the number widths and positions of the decimal points for decimal alignment @@ -101,21 +103,22 @@ def __count_columns(self) -> int: return len(self.__body[0]) return 0 - def __calculate_alignments( + def __determine_alignments( self, user_alignments: Sequence[Alignment] | Alignment | None, - default_alignments: Sequence[Alignment] | Alignment, + *, + 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_alignments: The default alignments to use if user_alignments is None + 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_alignments + 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): From a1e2bb55f8b01403623c02fcdf68e71ba683b027 Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Wed, 28 Dec 2022 17:00:19 -0700 Subject: [PATCH 3/5] Update test_alignments.py --- tests/test_alignments.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_alignments.py b/tests/test_alignments.py index 8edfa33..67aa5d3 100644 --- a/tests/test_alignments.py +++ b/tests/test_alignments.py @@ -172,3 +172,21 @@ def test_number_alignments(): "╚══════════════════════════════════════════════════════╝" ) 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 From fa9943253d2c8c71abbb08b0c0a921f1b0aeb9cf Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Wed, 28 Dec 2022 17:09:24 -0700 Subject: [PATCH 4/5] Update alignment.py --- table2ascii/alignment.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/table2ascii/alignment.py b/table2ascii/alignment.py index e6d4070..f11fa39 100644 --- a/table2ascii/alignment.py +++ b/table2ascii/alignment.py @@ -30,7 +30,7 @@ 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"], @@ -38,16 +38,16 @@ class Alignment(IntEnum): ["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 ║ ╚══════════════════════════════╝ \"\"\" From fac2a00ebc586b414c69ae37c69e48170c1fd7b0 Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Wed, 28 Dec 2022 17:16:52 -0700 Subject: [PATCH 5/5] Update docs --- table2ascii/table_to_ascii.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/table2ascii/table_to_ascii.py b/table2ascii/table_to_ascii.py index c84ad7d..c4359a8 100644 --- a/table2ascii/table_to_ascii.py +++ b/table2ascii/table_to_ascii.py @@ -701,8 +701,10 @@ def table2ascii( ``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. If not specified or set to :py:obj:`None`, numbers will be - aligned based on the ``alignments`` argument. Defaults to :py:obj:`None`. + 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