diff --git a/README.md b/README.md index 5689715..b5b50e2 100644 --- a/README.md +++ b/README.md @@ -195,20 +195,22 @@ print(output) ## ⚙️ Options -All parameters are optional. - -| Option | Type | Default | Description | -| :-----------------: | :-------------------: | :-------------------: | :-------------------------------------------------------------------------------: | -| `header` | `List[Any]` | `None` | First table row seperated by header row separator. Values should support `str()` | -| `body` | `List[List[Any]]` | `None` | List of rows for the main section of the table. Values should support `str()` | -| `footer` | `List[Any]` | `None` | Last table row seperated by header row separator. Values should support `str()` | -| `column_widths` | `List[Optional[int]]` | `None` (automatic) | List of column widths in characters for each column | -| `alignments` | `List[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 | +All parameters are optional. At least one of `header`, `body`, and `footer` must be provided. + +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 | [wcwidth]: https://pypi.org/project/wcwidth/ diff --git a/docs/source/generate_style_list.py b/docs/source/generate_style_list.py index 633b225..f71fdbe 100644 --- a/docs/source/generate_style_list.py +++ b/docs/source/generate_style_list.py @@ -4,12 +4,12 @@ from table2ascii import PresetStyle, table2ascii -def indent_all_lines(text, number_of_spaces=3): +def indent_all_lines(text: str, number_of_spaces: int = 3) -> str: """Indent all lines in a string by a certain number of spaces""" return "\n".join(number_of_spaces * " " + line for line in text.split("\n")) -def generate_style_list(): +def generate_style_list() -> str: """Generate README.rst the style list""" # get attributes in PresetStyle attribute_names = [attr for attr in dir(PresetStyle) if not attr.startswith("__")] @@ -43,7 +43,7 @@ def generate_style_list(): return f"{heading}\n\n{table_of_contents}\n{style_list}" -def write_to_file(filename, content): +def write_to_file(filename: str, content: str) -> None: """Write content to filename""" with open(filename, "w") as f: f.write(content) diff --git a/table2ascii/exceptions.py b/table2ascii/exceptions.py index 4af1a26..86be92a 100644 --- a/table2ascii/exceptions.py +++ b/table2ascii/exceptions.py @@ -1,15 +1,16 @@ from __future__ import annotations + +from collections.abc import Sequence from typing import Any from .alignment import Alignment - from .annotations import SupportsStr class Table2AsciiError(Exception): """Base class for all table2ascii exceptions""" - def _message(self): + def _message(self) -> str: """Return the error message""" raise NotImplementedError @@ -39,16 +40,16 @@ class FooterColumnCountMismatchError(ColumnCountMismatchError): This class is a subclass of :class:`ColumnCountMismatchError`. Attributes: - footer (list[SupportsStr]): The footer that caused the error + footer (Sequence[SupportsStr]): The footer that caused the error expected_columns (int): The number of columns that were expected """ - def __init__(self, footer: list[SupportsStr], expected_columns: int): + def __init__(self, footer: Sequence[SupportsStr], expected_columns: int): self.footer = footer self.expected_columns = expected_columns super().__init__(self._message()) - def _message(self): + def _message(self) -> str: return ( f"Footer column count mismatch: {len(self.footer)} columns " f"found, expected {self.expected_columns}." @@ -62,12 +63,12 @@ class BodyColumnCountMismatchError(ColumnCountMismatchError): This class is a subclass of :class:`ColumnCountMismatchError`. Attributes: - body (list[list[SupportsStr]]): The body that caused the error + body (Sequence[Sequence[SupportsStr]]): The body that caused the error expected_columns (int): The number of columns that were expected - first_invalid_row (list[SupportsStr]): The first row with an invalid column count + first_invalid_row (Sequence[SupportsStr]): The first row with an invalid column count """ - def __init__(self, body: list[list[SupportsStr]], expected_columns: int): + def __init__(self, body: Sequence[Sequence[SupportsStr]], expected_columns: int): self.body = body self.expected_columns = expected_columns self.first_invalid_row = next( @@ -75,7 +76,7 @@ def __init__(self, body: list[list[SupportsStr]], expected_columns: int): ) super().__init__(self._message()) - def _message(self): + def _message(self) -> str: return ( f"Body column count mismatch: A row with {len(self.first_invalid_row)} " f"columns was found, expected {self.expected_columns}." @@ -89,16 +90,16 @@ class AlignmentCountMismatchError(ColumnCountMismatchError): This class is a subclass of :class:`ColumnCountMismatchError`. Attributes: - alignments (list[Alignment]): The alignments that caused the error + alignments (Sequence[Alignment]): The alignments that caused the error expected_columns (int): The number of columns that were expected """ - def __init__(self, alignments: list[Alignment], expected_columns: int): + def __init__(self, alignments: Sequence[Alignment], expected_columns: int): self.alignments = alignments self.expected_columns = expected_columns super().__init__(self._message()) - def _message(self): + def _message(self) -> str: return ( f"Alignment count mismatch: {len(self.alignments)} alignments " f"found, expected {self.expected_columns}." @@ -112,22 +113,35 @@ class ColumnWidthsCountMismatchError(ColumnCountMismatchError): This class is a subclass of :class:`ColumnCountMismatchError`. Attributes: - column_widths (list[Optional[int]]): The column widths that caused the error + column_widths (Sequence[Optional[int]]): The column widths that caused the error expected_columns (int): The number of columns that were expected """ - def __init__(self, column_widths: list[int | None], expected_columns: int): + def __init__(self, column_widths: Sequence[int | None], expected_columns: int): self.column_widths = column_widths self.expected_columns = expected_columns super().__init__(self._message()) - def _message(self): + def _message(self) -> str: return ( f"Column widths count mismatch: {len(self.column_widths)} column widths " f"found, expected {self.expected_columns}." ) +class NoHeaderBodyOrFooterError(TableOptionError): + """Exception raised when no header, body or footer is provided + + This class is a subclass of :class:`TableOptionError`. + """ + + def __init__(self): + super().__init__(self._message()) + + def _message(self) -> str: + return "At least one of header, body or footer must be provided." + + class InvalidCellPaddingError(TableOptionError): """Exception raised when the cell padding is invalid @@ -141,7 +155,7 @@ def __init__(self, padding: int): self.padding = padding super().__init__(self._message()) - def _message(self): + def _message(self) -> str: return f"Invalid cell padding: {self.padding} is not a positive integer." @@ -163,7 +177,7 @@ def __init__(self, column_index: int, column_width: int, min_width: int): self.min_width = min_width super().__init__(self._message()) - def _message(self): + def _message(self) -> str: return ( f"Column width too small: The column width for column index {self.column_index} " f" of `column_widths` is {self.column_width}, but the minimum width " @@ -184,7 +198,7 @@ def __init__(self, alignment: Any): self.alignment = alignment super().__init__(self._message()) - def _message(self): + def _message(self) -> str: return ( f"Invalid alignment: {self.alignment!r} is not a valid alignment. " f"Valid alignments are: {', '.join(a.__repr__() for a in Alignment)}" @@ -208,7 +222,7 @@ def __init__(self, string: str, max_characters: int): self.max_characters = max_characters super().__init__(self._message()) - def _message(self): + def _message(self) -> str: return ( f"Too many characters for table style: {len(self.string)} characters " f"found, but the maximum number of characters allowed is {self.max_characters}." @@ -234,7 +248,7 @@ def __init__(self, string: str, max_characters: int): self.max_characters = max_characters super().__init__(self._message()) - def _message(self): + def _message(self) -> str: return ( f"Too few characters for table style: {len(self.string)} characters " f"found, but table styles can accept {self.max_characters} characters. " diff --git a/table2ascii/merge.py b/table2ascii/merge.py index 3e8888b..05b0073 100644 --- a/table2ascii/merge.py +++ b/table2ascii/merge.py @@ -39,5 +39,5 @@ class Merge(Enum): LEFT = 0 - def __str__(self): + def __str__(self) -> str: return "" diff --git a/table2ascii/options.py b/table2ascii/options.py index 9f73a16..36f6ee0 100644 --- a/table2ascii/options.py +++ b/table2ascii/options.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass from .alignment import Alignment @@ -17,8 +18,8 @@ class Options: first_col_heading: bool last_col_heading: bool - column_widths: list[int | None] | None - alignments: list[Alignment] | None + column_widths: Sequence[int | None] | None + alignments: Sequence[Alignment] | None cell_padding: int style: TableStyle use_wcwidth: bool diff --git a/table2ascii/table_style.py b/table2ascii/table_style.py index 620eb77..4336a06 100644 --- a/table2ascii/table_style.py +++ b/table2ascii/table_style.py @@ -128,11 +128,11 @@ def from_string(cls, string: str) -> "TableStyle": raise TableStyleTooLongError(string, num_params) # if the string is too short, show a warning and pad it with spaces elif len(string) < num_params: - string += " " * (num_params - len(string)) warnings.warn(TableStyleTooShortWarning(string, num_params), stacklevel=2) + string += " " * (num_params - len(string)) return cls(*string) - def set(self, **kwargs) -> "TableStyle": + def set(self, **kwargs: str) -> "TableStyle": """Set attributes of the TableStyle Args: diff --git a/table2ascii/table_to_ascii.py b/table2ascii/table_to_ascii.py index 835db3d..1b3a523 100644 --- a/table2ascii/table_to_ascii.py +++ b/table2ascii/table_to_ascii.py @@ -2,6 +2,7 @@ import textwrap from math import ceil, floor +from collections.abc import Sequence from wcwidth import wcswidth @@ -15,6 +16,7 @@ FooterColumnCountMismatchError, InvalidAlignmentError, InvalidCellPaddingError, + NoHeaderBodyOrFooterError, ) from .merge import Merge from .options import Options @@ -27,9 +29,9 @@ class TableToAscii: def __init__( self, - header: list[SupportsStr] | None, - body: list[list[SupportsStr]] | None, - footer: list[SupportsStr] | None, + header: Sequence[SupportsStr] | None, + body: Sequence[Sequence[SupportsStr]] | None, + footer: Sequence[SupportsStr] | None, options: Options, ): """Validate arguments and initialize fields @@ -41,9 +43,9 @@ def __init__( options: The options for the table """ # initialize fields - self.__header = header - self.__body = body - self.__footer = footer + self.__header = list(header) if header else None + self.__body = list([list(row) for row in body]) if body else None + self.__footer = list(footer) if footer else None self.__style = options.style self.__first_col_heading = options.first_col_heading self.__last_col_heading = options.last_col_heading @@ -60,6 +62,10 @@ def __init__( if body and any(len(row) != self.__columns for row in body): raise BodyColumnCountMismatchError(body, self.__columns) + # check that at least one of header, body, or footer is not None + 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) @@ -103,7 +109,7 @@ def widest_line(value: SupportsStr) -> int: text = str(value) return max(self.__str_width(line) for line in text.splitlines()) if len(text) else 0 - def get_column_width(row: list[SupportsStr], column: int) -> int: + def get_column_width(row: Sequence[SupportsStr], column: int) -> int: """Get the width of a cell in a column""" value = row[column] next_value = row[column + 1] if column < self.__columns - 1 else None @@ -122,7 +128,9 @@ def get_column_width(row: list[SupportsStr], column: int) -> int: column_widths.append(max(header_size, body_size, footer_size) + self.__cell_padding * 2) return column_widths - def __calculate_column_widths(self, user_column_widths: list[int | None] | None) -> list[int]: + def __calculate_column_widths( + self, user_column_widths: Sequence[int | None] | None + ) -> list[int]: """Calculate the width of each column in the table based on the cell values and provided column widths. Args: @@ -187,7 +195,7 @@ def __pad(self, cell_value: SupportsStr, width: int, alignment: Alignment) -> st raise InvalidAlignmentError(alignment) def __wrap_long_lines_in_merged_cells( - self, row: list[SupportsStr], column_separator: str + self, row: Sequence[SupportsStr], column_separator: str ) -> list[SupportsStr]: """Wrap long lines in merged cells to the width of the merged cell @@ -219,9 +227,9 @@ def __row_to_ascii( heading_col_sep: str, column_separator: str, right_edge: str, - filler: str | list[SupportsStr], - previous_content_row: list[SupportsStr] | None = None, - next_content_row: list[SupportsStr] | None = None, + filler: str | Sequence[SupportsStr], + previous_content_row: Sequence[SupportsStr] | None = None, + next_content_row: Sequence[SupportsStr] | None = None, top_tee: str | None = None, bottom_tee: str | None = None, heading_col_top_tee: str | None = None, @@ -266,9 +274,9 @@ def __line_in_row_to_ascii( heading_col_sep: str, column_separator: str, right_edge: str, - filler: str | list[SupportsStr], - previous_content_row: list[SupportsStr] | None = None, - next_content_row: list[SupportsStr] | None = None, + filler: str | Sequence[SupportsStr], + previous_content_row: Sequence[SupportsStr] | None = None, + next_content_row: Sequence[SupportsStr] | None = None, top_tee: str | None = None, bottom_tee: str | None = None, heading_col_top_tee: str | None = None, @@ -306,9 +314,9 @@ def __line_in_cell_column_to_ascii( heading_col_sep: str, column_separator: str, right_edge: str, - filler: str | list[SupportsStr], - previous_content_row: list[SupportsStr] | None = None, - next_content_row: list[SupportsStr] | None = None, + filler: str | Sequence[SupportsStr], + previous_content_row: Sequence[SupportsStr] | None = None, + next_content_row: Sequence[SupportsStr] | None = None, top_tee: str | None = None, bottom_tee: str | None = None, heading_col_top_tee: str | None = None, @@ -373,7 +381,7 @@ def __line_in_cell_column_to_ascii( return output + sep def __get_padded_cell_line_content( - self, line_index: int, col_index: int, column_separator: str, filler: list[SupportsStr] + self, line_index: int, col_index: int, column_separator: str, filler: Sequence[SupportsStr] ) -> str: # If this is a merge cell, merge with the previous column if filler[col_index] is Merge.LEFT: @@ -437,7 +445,7 @@ def __bottom_edge_to_ascii(self) -> str: heading_col_bottom_tee=self.__style.heading_col_bottom_tee, ) - def __content_row_to_ascii(self, row: list[SupportsStr]) -> str: + def __content_row_to_ascii(self, row: Sequence[SupportsStr]) -> str: """Assembles a row of cell values into a single line of the ascii table Returns: @@ -453,8 +461,8 @@ def __content_row_to_ascii(self, row: list[SupportsStr]) -> str: def __heading_sep_to_ascii( self, - previous_content_row: list[SupportsStr] | None = None, - next_content_row: list[SupportsStr] | None = None, + previous_content_row: Sequence[SupportsStr] | None = None, + next_content_row: Sequence[SupportsStr] | None = None, ) -> str: """Assembles the separator below the header or above footer of the ascii table @@ -475,7 +483,7 @@ def __heading_sep_to_ascii( heading_col_bottom_tee=self.__style.heading_col_heading_row_bottom_tee, ) - def __body_to_ascii(self, body: list[list[SupportsStr]]) -> str: + def __body_to_ascii(self, body: Sequence[Sequence[SupportsStr]]) -> str: """Assembles the body of the ascii table Returns: @@ -551,14 +559,14 @@ def to_ascii(self) -> str: def table2ascii( - header: list[SupportsStr] | None = None, - body: list[list[SupportsStr]] | None = None, - footer: list[SupportsStr] | None = None, + header: Sequence[SupportsStr] | None = None, + body: Sequence[Sequence[SupportsStr]] | None = None, + footer: Sequence[SupportsStr] | None = None, *, first_col_heading: bool = False, last_col_heading: bool = False, - column_widths: list[int | None] | None = None, - alignments: list[Alignment] | None = None, + column_widths: Sequence[int | None] | None = None, + alignments: Sequence[Alignment] | None = None, cell_padding: int = 1, style: TableStyle = PresetStyle.double_thin_compact, use_wcwidth: bool = True, @@ -581,8 +589,8 @@ def table2ascii( Defaults to :py:obj:`False`. column_widths: List of widths in characters for each column. Any value of :py:obj:`None` indicates that the column width should be determined automatically. If :py:obj:`None` - is passed instead of a :class:`list`, all columns will be automatically sized. - Defaults to :py:obj:`None`. + is passed instead of a :class:`~collections.abc.Sequence`, all columns will be automatically + sized. Defaults to :py:obj:`None`. alignments: List of alignments for each column (ex. ``[Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT]``). If not specified or set to :py:obj:`None`, all columns will be center-aligned. Defaults to :py:obj:`None`. diff --git a/tests/test_convert.py b/tests/test_convert.py index 60e696a..3030cd6 100644 --- a/tests/test_convert.py +++ b/tests/test_convert.py @@ -1,7 +1,11 @@ import pytest from table2ascii import table2ascii as t2a -from table2ascii.exceptions import BodyColumnCountMismatchError, FooterColumnCountMismatchError +from table2ascii.exceptions import ( + BodyColumnCountMismatchError, + FooterColumnCountMismatchError, + NoHeaderBodyOrFooterError, +) def test_header_body_footer(): @@ -117,6 +121,11 @@ def test_footer(): assert text == expected +def test_no_header_body_or_footer(): + with pytest.raises(NoHeaderBodyOrFooterError): + t2a() + + def test_header_footer_unequal(): with pytest.raises(FooterColumnCountMismatchError): t2a(