Skip to content

Commit 171e881

Browse files
authored
Merge pull request #9 from DenverCoder1/column-widths
2 parents ca41280 + 9e1054f commit 171e881

File tree

6 files changed

+208
-62
lines changed

6 files changed

+208
-62
lines changed

README.md

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,14 +65,44 @@ print(output)
6565
"""
6666
```
6767

68+
```py
69+
from table2ascii import table2ascii, Alignment
70+
71+
output = table2ascii(
72+
header=["#", "G", "H", "R", "S"],
73+
body=[["1", "30", "40", "35", "30"], ["2", "30", "40", "35", "30"]],
74+
first_col_heading=True,
75+
column_widths=[5] * 5, # [5, 5, 5, 5, 5]
76+
alignments=[Alignment.LEFT] + [Alignment.RIGHT] * 4, # First is left, remaining 4 are right
77+
)
78+
79+
print(output)
80+
81+
"""
82+
╔═════╦═══════════════════════╗
83+
║ # ║ G H R S ║
84+
╟─────╫───────────────────────╢
85+
║ 1 ║ 30 40 35 30 ║
86+
║ 2 ║ 30 40 35 30 ║
87+
╚═════╩═══════════════════════╝
88+
"""
89+
```
90+
6891
## ⚙️ Options
6992

93+
All parameters are optional.
94+
7095
Soon table2ascii will support more options for customization.
7196

72-
| Option | Type | Default | Description |
73-
| :-----------------: | :----: | :-----: | :--------------------------------------------------------------: |
74-
| `first_col_heading` | `bool` | `False` | Whether to add a heading column seperator after the first column |
75-
| `last_col_heading` | `bool` | `False` | Whether to add a heading column seperator before the last column |
97+
| Option | Type | Default | Description |
98+
| :-----------------: | :-------: | :----------: | :------------------------------------------------------------------------------------: |
99+
| `header` | `List` | `None` | First row of table seperated by header row seperator |
100+
| `body` | `2D List` | `None` | List of rows for the main section of the table |
101+
| `footer` | `List` | `None` | Last row of table seperated by header row seperator |
102+
| `column_widths` | `List` | automatic | List of column widths in characters for each column |
103+
| `alignments` | `List` | all centered | Alignments for each column (ex. `[Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT]`) |
104+
| `first_col_heading` | `bool` | `False` | Whether to add a heading column seperator after the first column |
105+
| `last_col_heading` | `bool` | `False` | Whether to add a heading column seperator before the last column |
76106

77107
## 👨‍🎨 Use cases
78108

setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def run(self):
5959

6060
setup(
6161
name="table2ascii",
62-
version="0.0.2",
62+
version="0.0.3",
6363
author="Jonah Lawrence",
6464
author_email="[email protected]",
6565
description="Convert 2D Python lists into Unicode/Ascii tables",

table2ascii/__init__.py

Lines changed: 78 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -4,53 +4,74 @@
44
from typing import List, Optional, Union
55

66

7+
class Alignment(enum.Enum):
8+
"""Enum for alignment types"""
9+
10+
LEFT = 0
11+
CENTER = 1
12+
RIGHT = 2
13+
14+
715
@dataclass
816
class Options:
917
"""Class for storing options that the user sets"""
1018

19+
header: Optional[List] = None
20+
body: Optional[List[List]] = None
21+
footer: Optional[List] = None
1122
first_col_heading: bool = False
1223
last_col_heading: bool = False
13-
14-
15-
class Alignment(enum.Enum):
16-
"""Enum for alignment types"""
17-
18-
LEFT = 0
19-
RIGHT = 1
20-
CENTER = 2
24+
column_widths: Optional[List[int]] = None
25+
alignments: Optional[List[Alignment]] = None
2126

2227

2328
class TableToAscii:
2429
"""Class used to convert a 2D Python table to ASCII text"""
2530

26-
def __init__(
27-
self,
28-
header: Optional[List],
29-
body: Optional[List[List]],
30-
footer: Optional[List],
31-
options: Options,
32-
):
31+
def __init__(self, options: Options):
3332
"""Validate arguments and initialize fields"""
34-
# check if columns in header are different from footer
35-
if header and footer and len(header) != len(footer):
36-
raise ValueError("Header row and footer row must have the same length")
37-
# check if columns in header are different from body
38-
if header and body and len(body) > 0 and len(header) != len(body[0]):
39-
raise ValueError("Header row and body rows must have the same length")
40-
# check if columns in header are different from body
41-
if footer and body and len(body) > 0 and len(footer) != len(body[0]):
42-
raise ValueError("Footer row and body rows must have the same length")
43-
# check if any rows in body have a different number of columns
44-
if body and len(body) and tuple(filter(lambda r: len(r) != len(body[0]), body)):
45-
raise ValueError("All rows in body must have the same length")
46-
4733
# initialize fields
48-
self.__header = header
49-
self.__body = body
50-
self.__footer = footer
51-
self.__options = options
34+
self.__header = options.header
35+
self.__body = options.body
36+
self.__footer = options.footer
37+
self.__first_col_heading = options.first_col_heading
38+
self.__last_col_heading = options.last_col_heading
39+
40+
# calculate number of columns
5241
self.__columns = self.__count_columns()
53-
self.__cell_widths = self.__get_column_widths()
42+
43+
# check if footer has a different number of columns
44+
if options.footer and len(options.footer) != self.__columns:
45+
raise ValueError(
46+
"Footer must have the same number of columns as the other rows"
47+
)
48+
# check if any rows in body have a different number of columns
49+
if options.body and any(len(row) != self.__columns for row in options.body):
50+
raise ValueError(
51+
"All rows in body must have the same number of columns as the other rows"
52+
)
53+
54+
# calculate or use given column widths
55+
self.__column_widths = options.column_widths or self.__auto_column_widths()
56+
57+
# check if column widths specified have a different number of columns
58+
if options.column_widths and len(options.column_widths) != self.__columns:
59+
raise ValueError(
60+
"Length of `column_widths` list must equal the number of columns"
61+
)
62+
# check if column widths are not all at least 2
63+
if options.column_widths and min(options.column_widths) < 2:
64+
raise ValueError(
65+
"All values in `column_widths` must be greater than or equal to 2"
66+
)
67+
68+
self.__alignments = options.alignments or [Alignment.CENTER] * self.__columns
69+
70+
# check if alignments specified have a different number of columns
71+
if options.alignments and len(options.alignments) != self.__columns:
72+
raise ValueError(
73+
"Length of `alignments` list must equal the number of columns"
74+
)
5475

5576
"""
5677
╔═════╦═══════════════════════╗ ABBBBBCBBBBBDBBBBBDBBBBBDBBBBBE
@@ -99,11 +120,11 @@ def __count_columns(self) -> int:
99120
return len(self.__body[0])
100121
return 0
101122

102-
def __get_column_widths(self) -> List[int]:
123+
def __auto_column_widths(self) -> List[int]:
103124
"""Get the minimum number of characters needed for the values
104125
in each column in the table with 1 space of padding on each side.
105126
"""
106-
col_counts = []
127+
column_widths = []
107128
for i in range(self.__columns):
108129
# number of characters in column of i of header, each body row, and footer
109130
header_size = len(self.__header[i]) if self.__header else 0
@@ -112,10 +133,10 @@ def __get_column_widths(self) -> List[int]:
112133
)
113134
footer_size = len(self.__footer[i]) if self.__footer else 0
114135
# get the max and add 2 for padding each side with a space
115-
col_counts.append(max(header_size, *body_size, footer_size) + 2)
116-
return col_counts
136+
column_widths.append(max(header_size, *body_size, footer_size) + 2)
137+
return column_widths
117138

118-
def __pad(self, text: str, width: int, alignment: Alignment = Alignment.CENTER):
139+
def __pad(self, text: str, width: int, alignment: Alignment):
119140
"""Pad a string of text to a given width with specified alignment"""
120141
if alignment == Alignment.LEFT:
121142
# pad with spaces on the end
@@ -139,24 +160,27 @@ def __row_to_ascii(
139160
filler: Union[str, List],
140161
) -> str:
141162
"""Assembles a row of the ascii table"""
142-
first_heading = self.__options.first_col_heading
143-
last_heading = self.__options.last_col_heading
144163
# left edge of the row
145164
output = left_edge
146165
# add columns
147166
for i in range(self.__columns):
148167
# content between separators
149168
output += (
150169
# edge or row separator if filler is a specific character
151-
filler * self.__cell_widths[i]
170+
filler * self.__column_widths[i]
152171
if isinstance(filler, str)
153172
# otherwise, use the column content
154-
else self.__pad(str(filler[i]), self.__cell_widths[i])
173+
else self.__pad(
174+
str(filler[i]), self.__column_widths[i], self.__alignments[i]
175+
)
155176
)
156177
# column seperator
157178
sep = column_seperator
158-
if (i == 0 and first_heading) or (i == self.__columns - 2 and last_heading):
159-
# use column heading if option is specified
179+
if i == 0 and self.__first_col_heading:
180+
# use column heading if first column option is specified
181+
sep = heading_col_sep
182+
elif i == self.__columns - 2 and self.__last_col_heading:
183+
# use column heading if last column option is specified
160184
sep = heading_col_sep
161185
elif i == self.__columns - 1:
162186
# replace last seperator with symbol for edge of the row
@@ -225,16 +249,16 @@ def __footer_sep_to_ascii(self) -> str:
225249
)
226250

227251
def __body_to_ascii(self) -> str:
228-
output: str = ""
229-
for row in self.__body:
230-
output += self.__row_to_ascii(
252+
return "".join(
253+
self.__row_to_ascii(
231254
left_edge=self.__parts["left_and_right_edge"],
232255
heading_col_sep=self.__parts["heading_col_sep"],
233256
column_seperator=self.__parts["middle_edge"],
234257
right_edge=self.__parts["left_and_right_edge"],
235258
filler=row,
236259
)
237-
return output
260+
for row in self.__body
261+
)
238262

239263
def to_ascii(self) -> str:
240264
# top row of table
@@ -256,17 +280,16 @@ def to_ascii(self) -> str:
256280
return table
257281

258282

259-
def table2ascii(
260-
header: Optional[List] = None,
261-
body: Optional[List[List]] = None,
262-
footer: Optional[List] = None,
263-
**options,
264-
) -> str:
283+
def table2ascii(**options) -> str:
265284
"""Convert a 2D Python table to ASCII text
266285
267286
### Arguments
268287
:param header: :class:`Optional[List]` List of column values in the table's header row
269288
:param body: :class:`Optional[List[List]]` 2-dimensional list of values in the table's body
270289
:param footer: :class:`Optional[List]` List of column values in the table's footer row
290+
:param column_widths: :class:`Optional[List[int]]` List of widths in characters for each column (defaults to auto-sizing)
291+
:param alignments: :class:`Optional[List[Alignment]]` List of alignments (ex. `[Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT]`)
292+
:param first_col_heading: :class:`Optional[bool]` Whether to add a header column separator after the first column
293+
:param last_col_heading: :class:`Optional[bool]` Whether to add a header column separator before the last column
271294
"""
272-
return TableToAscii(header, body, footer, Options(**options)).to_ascii()
295+
return TableToAscii(Options(**options)).to_ascii()

tests/test_alignments.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from table2ascii import table2ascii as t2a, Alignment
2+
3+
import pytest
4+
5+
6+
def test_first_left_four_right():
7+
text = t2a(
8+
header=["#", "G", "H", "R", "S"],
9+
body=[["1", "30", "40", "35", "30"], ["2", "30", "40", "35", "30"]],
10+
footer=["SUM", "130", "140", "135", "130"],
11+
first_col_heading=True,
12+
alignments=[Alignment.LEFT] + [Alignment.RIGHT] * 4,
13+
)
14+
expected = (
15+
"╔═════╦═══════════════════════╗\n"
16+
"║ # ║ G H R S ║\n"
17+
"╟─────╫───────────────────────╢\n"
18+
"║ 1 ║ 30 40 35 30 ║\n"
19+
"║ 2 ║ 30 40 35 30 ║\n"
20+
"╟─────╫───────────────────────╢\n"
21+
"║ SUM ║ 130 140 135 130 ║\n"
22+
"╚═════╩═══════════════════════╝\n"
23+
)
24+
assert text == expected
25+
26+
27+
def test_wrong_number_alignments():
28+
with pytest.raises(ValueError):
29+
t2a(
30+
header=["#", "G", "H", "R", "S"],
31+
body=[["1", "30", "40", "35", "30"], ["2", "30", "40", "35", "30"]],
32+
footer=["SUM", "130", "140", "135", "130"],
33+
first_col_heading=True,
34+
alignments=[Alignment.LEFT, Alignment.CENTER, Alignment.RIGHT],
35+
)
36+
37+
38+
def test_invalid_alignments():
39+
with pytest.raises(ValueError):
40+
t2a(
41+
header=["#", "G", "H", "R", "S"],
42+
body=[["1", "30", "40", "35", "30"], ["2", "30", "40", "35", "30"]],
43+
footer=["SUM", "130", "140", "135", "130"],
44+
first_col_heading=True,
45+
alignments=[9999, -1, Alignment.RIGHT, Alignment.CENTER, Alignment.RIGHT],
46+
)

tests/test_column_widths.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from table2ascii import table2ascii as t2a
2+
3+
import pytest
4+
5+
6+
def test_column_widths():
7+
text = t2a(
8+
header=["#", "G", "H", "R", "S"],
9+
body=[["1", "30", "40", "35", "30"], ["2", "30", "40", "35", "30"]],
10+
footer=["TOTL", "130", "140", "135", "130"],
11+
first_col_heading=True,
12+
last_col_heading=True,
13+
column_widths=[7, 5, 5, 5, 8],
14+
)
15+
expected = (
16+
"╔═══════╦═════════════════╦════════╗\n"
17+
"║ # ║ G H R ║ S ║\n"
18+
"╟───────╫─────────────────╫────────╢\n"
19+
"║ 1 ║ 30 40 35 ║ 30 ║\n"
20+
"║ 2 ║ 30 40 35 ║ 30 ║\n"
21+
"╟───────╫─────────────────╫────────╢\n"
22+
"║ TOTL ║ 130 140 135 ║ 130 ║\n"
23+
"╚═══════╩═════════════════╩════════╝\n"
24+
)
25+
assert text == expected
26+
27+
28+
def test_wrong_number_column_widths():
29+
with pytest.raises(ValueError):
30+
t2a(
31+
header=["#", "G", "H", "R", "S"],
32+
body=[["1", "30", "40", "35", "30"], ["2", "30", "40", "35", "30"]],
33+
footer=["TOTL", "130", "140", "135", "130"],
34+
first_col_heading=True,
35+
last_col_heading=True,
36+
column_widths=[7, 5, 5, 5],
37+
)
38+
39+
40+
def test_negative_column_widths():
41+
with pytest.raises(ValueError):
42+
t2a(
43+
header=["#", "G", "H", "R", "S"],
44+
body=[["1", "30", "40", "35", "30"], ["2", "30", "40", "35", "30"]],
45+
footer=["TOTL", "130", "140", "135", "130"],
46+
first_col_heading=True,
47+
last_col_heading=True,
48+
column_widths=[7, 5, 5, 5, -1],
49+
)

tests/test_heading_cols.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
from table2ascii import table2ascii as t2a
22

3-
import pytest
4-
53

64
def test_first_column_heading():
75
text = t2a(

0 commit comments

Comments
 (0)