From 48d8882c07a03bd9d86a1992fc4c3b86a92cf4c9 Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Tue, 27 Apr 2021 17:10:56 +0300 Subject: [PATCH 1/8] Add column width option --- table2ascii/__init__.py | 59 ++++++++++++++++++++++++------------- tests/test_column_widths.py | 49 ++++++++++++++++++++++++++++++ tests/test_heading_cols.py | 2 -- 3 files changed, 87 insertions(+), 23 deletions(-) create mode 100644 tests/test_column_widths.py diff --git a/table2ascii/__init__.py b/table2ascii/__init__.py index 19c7a6d..9a7dd99 100644 --- a/table2ascii/__init__.py +++ b/table2ascii/__init__.py @@ -28,29 +28,41 @@ def __init__( header: Optional[List], body: Optional[List[List]], footer: Optional[List], + column_widths: Optional[List[int]], options: Options, ): """Validate arguments and initialize fields""" - # check if columns in header are different from footer - if header and footer and len(header) != len(footer): - raise ValueError("Header row and footer row must have the same length") - # check if columns in header are different from body - if header and body and len(body) > 0 and len(header) != len(body[0]): - raise ValueError("Header row and body rows must have the same length") - # check if columns in header are different from body - if footer and body and len(body) > 0 and len(footer) != len(body[0]): - raise ValueError("Footer row and body rows must have the same length") - # check if any rows in body have a different number of columns - if body and len(body) and tuple(filter(lambda r: len(r) != len(body[0]), body)): - raise ValueError("All rows in body must have the same length") - # initialize fields self.__header = header self.__body = body self.__footer = footer self.__options = options self.__columns = self.__count_columns() - self.__cell_widths = self.__get_column_widths() + + # check if footer has a different number of columns + if footer and len(footer) != self.__columns: + raise ValueError( + "Footer must have the same number of columns as the other rows" + ) + # check if any rows in body have a different number of columns + if body and tuple(filter(lambda r: len(r) != self.__columns, body)): + raise ValueError( + "All rows in body must have the same number of columns as the other rows" + ) + + # calculate column widths + self.__column_widths = column_widths or self.__auto_column_widths() + + # check if column widths specified have a different number of columns + if column_widths and len(column_widths) != self.__columns: + raise ValueError( + "Length of `column_widths` list must equal the number of columns" + ) + # check if column widths are not all at least 2 + if column_widths and min(column_widths) < 2: + raise ValueError( + "All values in `column_widths` must be greater than or equal to 2" + ) """ ╔═════╦═══════════════════════╗ ABBBBBCBBBBBDBBBBBDBBBBBDBBBBBE @@ -99,11 +111,11 @@ def __count_columns(self) -> int: return len(self.__body[0]) return 0 - def __get_column_widths(self) -> List[int]: + 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. """ - col_counts = [] + column_widths = [] for i in range(self.__columns): # number of characters in column of i of header, each body row, and footer header_size = len(self.__header[i]) if self.__header else 0 @@ -112,8 +124,8 @@ def __get_column_widths(self) -> List[int]: ) footer_size = len(self.__footer[i]) if self.__footer else 0 # get the max and add 2 for padding each side with a space - col_counts.append(max(header_size, *body_size, footer_size) + 2) - return col_counts + column_widths.append(max(header_size, *body_size, footer_size) + 2) + return column_widths def __pad(self, text: str, width: int, alignment: Alignment = Alignment.CENTER): """Pad a string of text to a given width with specified alignment""" @@ -148,10 +160,10 @@ def __row_to_ascii( # content between separators output += ( # edge or row separator if filler is a specific character - filler * self.__cell_widths[i] + filler * self.__column_widths[i] if isinstance(filler, str) # otherwise, use the column content - else self.__pad(str(filler[i]), self.__cell_widths[i]) + else self.__pad(str(filler[i]), self.__column_widths[i]) ) # column seperator sep = column_seperator @@ -260,6 +272,7 @@ def table2ascii( header: Optional[List] = None, body: Optional[List[List]] = None, footer: Optional[List] = None, + column_widths: Optional[List[int]] = None, **options, ) -> str: """Convert a 2D Python table to ASCII text @@ -268,5 +281,9 @@ def table2ascii( :param header: :class:`Optional[List]` List of column values in the table's header row :param body: :class:`Optional[List[List]]` 2-dimensional list of values in the table's body :param footer: :class:`Optional[List]` List of column values in the table's footer row + :param column_widths: :class:`Optional[List[int]]` List of widths in characters for each column (defaults to auto-sizing) + :param footer: :class:`Optional[List]` List of column values in the table's footer row """ - return TableToAscii(header, body, footer, Options(**options)).to_ascii() + return TableToAscii( + header, body, footer, column_widths, Options(**options) + ).to_ascii() diff --git a/tests/test_column_widths.py b/tests/test_column_widths.py new file mode 100644 index 0000000..28be80d --- /dev/null +++ b/tests/test_column_widths.py @@ -0,0 +1,49 @@ +from table2ascii import table2ascii as t2a + +import pytest + + +def test_column_widths(): + text = t2a( + header=["#", "G", "H", "R", "S"], + body=[["1", "30", "40", "35", "30"], ["2", "30", "40", "35", "30"]], + footer=["TOTL", "130", "140", "135", "130"], + first_col_heading=True, + last_col_heading=True, + column_widths=[7, 5, 5, 5, 8], + ) + expected = ( + "╔═══════╦═════════════════╦════════╗\n" + "║ # ║ G H R ║ S ║\n" + "╟───────╫─────────────────╫────────╢\n" + "║ 1 ║ 30 40 35 ║ 30 ║\n" + "║ 2 ║ 30 40 35 ║ 30 ║\n" + "╟───────╫─────────────────╫────────╢\n" + "║ TOTL ║ 130 140 135 ║ 130 ║\n" + "╚═══════╩═════════════════╩════════╝\n" + ) + assert text == expected + + +def test_wrong_number_column_widths(): + with pytest.raises(ValueError): + t2a( + header=["#", "G", "H", "R", "S"], + body=[["1", "30", "40", "35", "30"], ["2", "30", "40", "35", "30"]], + footer=["TOTL", "130", "140", "135", "130"], + first_col_heading=True, + last_col_heading=True, + column_widths=[7, 5, 5, 5], + ) + + +def test_negative_column_widths(): + with pytest.raises(ValueError): + t2a( + header=["#", "G", "H", "R", "S"], + body=[["1", "30", "40", "35", "30"], ["2", "30", "40", "35", "30"]], + footer=["TOTL", "130", "140", "135", "130"], + first_col_heading=True, + last_col_heading=True, + column_widths=[7, 5, 5, 5, -1], + ) diff --git a/tests/test_heading_cols.py b/tests/test_heading_cols.py index a231dc0..62cf5b5 100644 --- a/tests/test_heading_cols.py +++ b/tests/test_heading_cols.py @@ -1,7 +1,5 @@ from table2ascii import table2ascii as t2a -import pytest - def test_first_column_heading(): text = t2a( From 7cf0565d6e2c91a4060d55a12dee523b80de848e Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Tue, 27 Apr 2021 17:17:43 +0300 Subject: [PATCH 2/8] Added alignment option and test --- table2ascii/__init__.py | 22 +++++++++++++++++----- tests/test_alignments.py | 22 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) create mode 100644 tests/test_alignments.py diff --git a/table2ascii/__init__.py b/table2ascii/__init__.py index 9a7dd99..8d68598 100644 --- a/table2ascii/__init__.py +++ b/table2ascii/__init__.py @@ -29,6 +29,7 @@ def __init__( body: Optional[List[List]], footer: Optional[List], column_widths: Optional[List[int]], + alignments: Optional[List[Alignment]], options: Options, ): """Validate arguments and initialize fields""" @@ -37,6 +38,8 @@ def __init__( self.__body = body self.__footer = footer self.__options = options + + # calculate number of columns self.__columns = self.__count_columns() # check if footer has a different number of columns @@ -50,7 +53,7 @@ def __init__( "All rows in body must have the same number of columns as the other rows" ) - # calculate column widths + # calculate or use given column widths self.__column_widths = column_widths or self.__auto_column_widths() # check if column widths specified have a different number of columns @@ -64,6 +67,8 @@ def __init__( "All values in `column_widths` must be greater than or equal to 2" ) + self.__alignments = alignments or [Alignment.CENTER] * self.__columns + """ ╔═════╦═══════════════════════╗ ABBBBBCBBBBBDBBBBBDBBBBBDBBBBBE ║ # ║ G H R S ║ F G H H H F @@ -127,7 +132,7 @@ def __auto_column_widths(self) -> List[int]: column_widths.append(max(header_size, *body_size, footer_size) + 2) return column_widths - def __pad(self, text: str, width: int, alignment: Alignment = Alignment.CENTER): + def __pad(self, text: str, width: int, alignment: Alignment): """Pad a string of text to a given width with specified alignment""" if alignment == Alignment.LEFT: # pad with spaces on the end @@ -163,7 +168,9 @@ def __row_to_ascii( filler * self.__column_widths[i] if isinstance(filler, str) # otherwise, use the column content - else self.__pad(str(filler[i]), self.__column_widths[i]) + else self.__pad( + str(filler[i]), self.__column_widths[i], self.__alignments[i] + ) ) # column seperator sep = column_seperator @@ -273,6 +280,7 @@ def table2ascii( body: Optional[List[List]] = None, footer: Optional[List] = None, column_widths: Optional[List[int]] = None, + alignments: Optional[List[Alignment]] = None, **options, ) -> str: """Convert a 2D Python table to ASCII text @@ -282,8 +290,12 @@ def table2ascii( :param body: :class:`Optional[List[List]]` 2-dimensional list of values in the table's body :param footer: :class:`Optional[List]` List of column values in the table's footer row :param column_widths: :class:`Optional[List[int]]` List of widths in characters for each column (defaults to auto-sizing) - :param footer: :class:`Optional[List]` List of column values in the table's footer row + :param alignments: :class:`Optional[List[Alignment]]` List of alignments (ex. `[Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT]`) + + ### Additional options + :param first_col_heading: :class:`Optional[bool]` Whether to add a header column separator after the first column + :param last_col_heading: :class:`Optional[bool]` Whether to add a header column separator before the last column """ return TableToAscii( - header, body, footer, column_widths, Options(**options) + header, body, footer, column_widths, alignments, Options(**options) ).to_ascii() diff --git a/tests/test_alignments.py b/tests/test_alignments.py new file mode 100644 index 0000000..31984dc --- /dev/null +++ b/tests/test_alignments.py @@ -0,0 +1,22 @@ +from table2ascii import table2ascii as t2a, Alignment + + +def test_first_left_four_right(): + text = t2a( + header=["#", "G", "H", "R", "S"], + body=[["1", "30", "40", "35", "30"], ["2", "30", "40", "35", "30"]], + footer=["SUM", "130", "140", "135", "130"], + first_col_heading=True, + alignments=[Alignment.LEFT] + [Alignment.RIGHT] * 4, + ) + expected = ( + "╔═════╦═══════════════════════╗\n" + "║ # ║ G H R S ║\n" + "╟─────╫───────────────────────╢\n" + "║ 1 ║ 30 40 35 30 ║\n" + "║ 2 ║ 30 40 35 30 ║\n" + "╟─────╫───────────────────────╢\n" + "║ SUM ║ 130 140 135 130 ║\n" + "╚═════╩═══════════════════════╝\n" + ) + assert text == expected From 7c7c579d1da5bfa30d3b60b3ab5e53c24d1c1768 Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Tue, 27 Apr 2021 17:24:42 +0300 Subject: [PATCH 3/8] Add more alignment tests --- table2ascii/__init__.py | 6 ++++++ tests/test_alignments.py | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/table2ascii/__init__.py b/table2ascii/__init__.py index 8d68598..bc5da9e 100644 --- a/table2ascii/__init__.py +++ b/table2ascii/__init__.py @@ -69,6 +69,12 @@ def __init__( self.__alignments = alignments or [Alignment.CENTER] * self.__columns + # check if alignments specified have a different number of columns + if alignments and len(alignments) != self.__columns: + raise ValueError( + "Length of `alignments` list must equal the number of columns" + ) + """ ╔═════╦═══════════════════════╗ ABBBBBCBBBBBDBBBBBDBBBBBDBBBBBE ║ # ║ G H R S ║ F G H H H F diff --git a/tests/test_alignments.py b/tests/test_alignments.py index 31984dc..95dfbd2 100644 --- a/tests/test_alignments.py +++ b/tests/test_alignments.py @@ -1,5 +1,7 @@ from table2ascii import table2ascii as t2a, Alignment +import pytest + def test_first_left_four_right(): text = t2a( @@ -20,3 +22,25 @@ def test_first_left_four_right(): "╚═════╩═══════════════════════╝\n" ) assert text == expected + + +def test_wrong_number_alignments(): + with pytest.raises(ValueError): + t2a( + header=["#", "G", "H", "R", "S"], + body=[["1", "30", "40", "35", "30"], ["2", "30", "40", "35", "30"]], + footer=["SUM", "130", "140", "135", "130"], + first_col_heading=True, + alignments=[Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT], + ) + + +def test_invalid_alignments(): + with pytest.raises(ValueError): + t2a( + header=["#", "G", "H", "R", "S"], + body=[["1", "30", "40", "35", "30"], ["2", "30", "40", "35", "30"]], + footer=["SUM", "130", "140", "135", "130"], + first_col_heading=True, + alignments=[9999, -1, Alignment.RIGHT], + ) From aafcfe5c531da61d58aee1b3e3ee2c295c6e4ae5 Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Tue, 27 Apr 2021 17:35:30 +0300 Subject: [PATCH 4/8] Add column widths and alignments to readme examples --- README.md | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 4a61c08..4fb1767 100644 --- a/README.md +++ b/README.md @@ -65,14 +65,44 @@ print(output) """ ``` +```py +from table2ascii import table2ascii + +output = table2ascii( + header=["#", "G", "H", "R", "S"], + body=[["1", "30", "40", "35", "30"], ["2", "30", "40", "35", "30"]], + first_col_heading=True, + column_widths=[5] * 5, # [5, 5, 5, 5, 5] + alignments=[Alignment.LEFT] + [Alignment.RIGHT] * 4, # First is left, remaining 4 are right +) + +print(output) + +""" +╔═════╦═══════════════════════╗ +║ # ║ G H R S ║ +╟─────╫───────────────────────╢ +║ 1 ║ 30 40 35 30 ║ +║ 2 ║ 30 40 35 30 ║ +╚═════╩═══════════════════════╝ +""" +``` + ## ⚙️ Options +All parameters are optional. + Soon table2ascii will support more options for customization. -| Option | Type | Default | Description | -| :-----------------: | :----: | :-----: | :--------------------------------------------------------------: | -| `first_col_heading` | `bool` | `False` | Whether to add a heading column seperator after the first column | -| `last_col_heading` | `bool` | `False` | Whether to add a heading column seperator before the last column | +| Option | Type | Default | Description | +| :-----------------: | :-------: | :----------: | :------------------------------------------------------------------------------------: | +| `header` | `List` | `None` | First row of table seperated by header row seperator | +| `body` | `2D List` | `None` | List of rows for the main section of the table | +| `footer` | `List` | `None` | Last row of table seperated by header row seperator | +| `column_widths` | `List` | automatic | List of column widths in characters for each column | +| `alignments` | `List` | all centered | Alignments for each column (ex. `[Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT]`) | +| `first_col_heading` | `bool` | `False` | Whether to add a heading column seperator after the first column | +| `last_col_heading` | `bool` | `False` | Whether to add a heading column seperator before the last column | ## 👨‍🎨 Use cases From c8346a99f3e8d2a2ccead3b7d570acb0ec890e2e Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Tue, 27 Apr 2021 17:42:07 +0300 Subject: [PATCH 5/8] Add import of alignment --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4fb1767..611d56c 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ print(output) ``` ```py -from table2ascii import table2ascii +from table2ascii import table2ascii, Alignment output = table2ascii( header=["#", "G", "H", "R", "S"], From 7e642ae7abc905ae21601d21ea72182e6b656a2d Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Tue, 27 Apr 2021 17:46:35 +0300 Subject: [PATCH 6/8] Swap lines internally in enum --- table2ascii/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/table2ascii/__init__.py b/table2ascii/__init__.py index bc5da9e..f2ed8ee 100644 --- a/table2ascii/__init__.py +++ b/table2ascii/__init__.py @@ -16,8 +16,8 @@ class Alignment(enum.Enum): """Enum for alignment types""" LEFT = 0 - RIGHT = 1 - CENTER = 2 + CENTER = 1 + RIGHT = 2 class TableToAscii: From 5414b4d4d169ba7a5c52d2ae780102fd78c3203c Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Tue, 27 Apr 2021 19:56:12 +0300 Subject: [PATCH 7/8] Move all parameters to Options and simplified code --- table2ascii/__init__.py | 86 +++++++++++++++++----------------------- tests/test_alignments.py | 2 +- 2 files changed, 38 insertions(+), 50 deletions(-) diff --git a/table2ascii/__init__.py b/table2ascii/__init__.py index f2ed8ee..dc29de7 100644 --- a/table2ascii/__init__.py +++ b/table2ascii/__init__.py @@ -4,14 +4,6 @@ from typing import List, Optional, Union -@dataclass -class Options: - """Class for storing options that the user sets""" - - first_col_heading: bool = False - last_col_heading: bool = False - - class Alignment(enum.Enum): """Enum for alignment types""" @@ -20,57 +12,63 @@ class Alignment(enum.Enum): RIGHT = 2 +@dataclass +class Options: + """Class for storing options that the user sets""" + + header: Optional[List] = None + body: Optional[List[List]] = None + footer: Optional[List] = None + first_col_heading: bool = False + last_col_heading: bool = False + column_widths: Optional[List[int]] = None + alignments: Optional[List[Alignment]] = None + + class TableToAscii: """Class used to convert a 2D Python table to ASCII text""" - def __init__( - self, - header: Optional[List], - body: Optional[List[List]], - footer: Optional[List], - column_widths: Optional[List[int]], - alignments: Optional[List[Alignment]], - options: Options, - ): + def __init__(self, options: Options): """Validate arguments and initialize fields""" # initialize fields - self.__header = header - self.__body = body - self.__footer = footer - self.__options = options + self.__header = options.header + self.__body = options.body + self.__footer = options.footer + self.__first_col_heading = options.first_col_heading + self.__last_col_heading = options.last_col_heading # calculate number of columns self.__columns = self.__count_columns() # check if footer has a different number of columns - if footer and len(footer) != self.__columns: + if options.footer and len(options.footer) != self.__columns: raise ValueError( "Footer must have the same number of columns as the other rows" ) # check if any rows in body have a different number of columns - if body and tuple(filter(lambda r: len(r) != self.__columns, body)): + if options.body and any(len(row) != self.__columns for row in options.body): raise ValueError( "All rows in body must have the same number of columns as the other rows" ) # calculate or use given column widths - self.__column_widths = column_widths or self.__auto_column_widths() + self.__column_widths = options.column_widths or self.__auto_column_widths() # check if column widths specified have a different number of columns - if column_widths and len(column_widths) != self.__columns: + if options.column_widths and len(options.column_widths) != self.__columns: raise ValueError( "Length of `column_widths` list must equal the number of columns" ) # check if column widths are not all at least 2 - if column_widths and min(column_widths) < 2: + if options.column_widths and min(options.column_widths) < 2: raise ValueError( "All values in `column_widths` must be greater than or equal to 2" ) - self.__alignments = alignments or [Alignment.CENTER] * self.__columns + self.__alignments = options.alignments or [Alignment.CENTER] * self.__columns # check if alignments specified have a different number of columns - if alignments and len(alignments) != self.__columns: + if options.alignments and len(options.alignments) != self.__columns: raise ValueError( "Length of `alignments` list must equal the number of columns" ) @@ -162,8 +160,6 @@ def __row_to_ascii( filler: Union[str, List], ) -> str: """Assembles a row of the ascii table""" - first_heading = self.__options.first_col_heading - last_heading = self.__options.last_col_heading # left edge of the row output = left_edge # add columns @@ -180,8 +176,11 @@ def __row_to_ascii( ) # column seperator sep = column_seperator - if (i == 0 and first_heading) or (i == self.__columns - 2 and last_heading): - # use column heading if option is specified + if i == 0 and self.__first_col_heading: + # use column heading if first column option is specified + sep = heading_col_sep + elif i == self.__columns - 2 and self.__last_col_heading: + # use column heading if last column option is specified sep = heading_col_sep elif i == self.__columns - 1: # replace last seperator with symbol for edge of the row @@ -250,16 +249,16 @@ def __footer_sep_to_ascii(self) -> str: ) def __body_to_ascii(self) -> str: - output: str = "" - for row in self.__body: - output += self.__row_to_ascii( + return "".join( + self.__row_to_ascii( left_edge=self.__parts["left_and_right_edge"], heading_col_sep=self.__parts["heading_col_sep"], column_seperator=self.__parts["middle_edge"], right_edge=self.__parts["left_and_right_edge"], filler=row, ) - return output + for row in self.__body + ) def to_ascii(self) -> str: # top row of table @@ -281,14 +280,7 @@ def to_ascii(self) -> str: return table -def table2ascii( - header: Optional[List] = None, - body: Optional[List[List]] = None, - footer: Optional[List] = None, - column_widths: Optional[List[int]] = None, - alignments: Optional[List[Alignment]] = None, - **options, -) -> str: +def table2ascii(**options) -> str: """Convert a 2D Python table to ASCII text ### Arguments @@ -297,11 +289,7 @@ def table2ascii( :param footer: :class:`Optional[List]` List of column values in the table's footer row :param column_widths: :class:`Optional[List[int]]` List of widths in characters for each column (defaults to auto-sizing) :param alignments: :class:`Optional[List[Alignment]]` List of alignments (ex. `[Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT]`) - - ### Additional options :param first_col_heading: :class:`Optional[bool]` Whether to add a header column separator after the first column :param last_col_heading: :class:`Optional[bool]` Whether to add a header column separator before the last column """ - return TableToAscii( - header, body, footer, column_widths, alignments, Options(**options) - ).to_ascii() + return TableToAscii(Options(**options)).to_ascii() diff --git a/tests/test_alignments.py b/tests/test_alignments.py index 95dfbd2..12667dc 100644 --- a/tests/test_alignments.py +++ b/tests/test_alignments.py @@ -42,5 +42,5 @@ def test_invalid_alignments(): body=[["1", "30", "40", "35", "30"], ["2", "30", "40", "35", "30"]], footer=["SUM", "130", "140", "135", "130"], first_col_heading=True, - alignments=[9999, -1, Alignment.RIGHT], + alignments=[9999, -1, Alignment.RIGHT, Alignment.CENTER, Alignment.RIGHT], ) From 9e1054fe647037ac7ffe8ec2af9be69f78f0acb6 Mon Sep 17 00:00:00 2001 From: Jonah Lawrence Date: Tue, 27 Apr 2021 19:58:35 +0300 Subject: [PATCH 8/8] Bump to 0.0.3 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 28159b0..fec0b7c 100644 --- a/setup.py +++ b/setup.py @@ -59,7 +59,7 @@ def run(self): setup( name="table2ascii", - version="0.0.2", + version="0.0.3", author="Jonah Lawrence", author_email="jonah@freshidea.com", description="Convert 2D Python lists into Unicode/Ascii tables",