First pass
Good job in doing a first pass at type hinting! In the newest stable version of Python (3.10 as of this writing) it's no longer necessary to import List
- you can hint with the built-in list
. However, your head
and body
shouldn't really be hinted as lists (which imply mutability), but instead Sequence
, which will also accept immutable tuples.
When you do simple member assignment in a constructor from parameters, the members don't also need type hints, and their type can be inferred.
Your # Table symbols
can all be deleted since you don't use them. I think the code is quite legible without declaring these as constants. If you were to keep (and start using) these constants, you would want to move them out to static scope before the constructor.
Your print
is not general-purpose enough. For example, if someone wants to write this table out to a file, that will be difficult. One convenient (and possibly the highest-performing) way of rewriting this is as an iterator of lines. The caller can decide to either iterate over each line and do something with it; or call into a wrapper utility method that joins the lines and returns a single string.
The first three f-strings in your print
method have trivial wrappers with a very long, single field in the middle. This is difficult to read, and you're better off moving that field content to a variable. Bonus: the variable name will self-document the line after it, so you can remove your comments.
When you join
on a generator, don't also wrap the generator into a list comprehension. Generators are perfectly capable of being passed bare into join
.
Don't for i, _ in enumerate
; this is a job for a simple i in range(len
.
In your main
loop, don't index + 1
; pass a start parameter to enumerate
.
In your body loop, rather than an enumerate
, zip
together your widths and elements.
find_widths
is a good candidate for being re-expressed as an iterator function. Store it to a tuple and not a list in the constructor.
Your test table content in main
should (almost) all be converted to tuples, with the exception of your outer body list comprehension since there's no such thing as a tuple comprehension, so a list is more convenient.
from typing import Sequence, Iterator
import random
class Table:
def __init__(self, head: Sequence[str], body: Sequence[Sequence[str]]) -> None:
self.head = head
self.body = body
self.column_widths: tuple[int] = tuple(self.find_widths())
def __str__(self) -> str:
return '\n'.join(self.lines())
def lines(self) -> Iterator[str]:
top = '┬'.join(
'─' * self.column_widths[i]
for i in range(len(self.head))
)
yield f"┌{top}┐"
header = '│'.join(
title.center(width)
for title, width in zip(self.head, self.column_widths)
)
yield f"│{header}│"
separator = '┼'.join(
'─' * self.column_widths[i]
for i in range(len(self.head))
)
yield f"├{separator}┤"
for row in self.body:
row_str = "│".join(
element.ljust(width, ' ')
for element, width in zip(row, self.column_widths)
)
yield f"│{row_str}│"
tail = '┴'.join(
'─' * self.column_widths[i]
for i in range(len(self.head))
)
yield f"└{tail}┘"
def find_widths(self) -> Iterator[int]:
table = (*self.body, self.head)
for index in range(len(self.head)):
yield max(len(row[index]) for row in table)
def main() -> None:
names = (
"Alpha", "Bravo", "Charlie", "Delta", "Echo",
"Foxtrot", "Golf", "Hotel", "India", "Juliet",
"Kilo", "Lima", "Mike", "November", "Oscar",
"Papa", "Quebec", "Romeo", "Sierra", "Tango",
"Uniform", "Victor", "Whiskey", "X-Ray", "Yankee",
"Zulu",
)
table1: Table = Table(
head=("Id", "Name ", "Marks"),
body=[
(
str(index).center(4),
name,
str(random.randint(0, 100)).rjust(5, ' ')
) for index, name in enumerate(names, 1)
],
)
print(table1)
if __name__ == "__main__":
main()
Second pass
Now, take a look at the commonalities in your formatting code. You basically only have two operations: making a hyphen-separator, and making a line with content. Put these in utility functions rather than copy-pasting the code:
from typing import Sequence, Iterator, Iterable
import random
class Table:
def __init__(self, head: Sequence[str], body: Sequence[Sequence[str]]) -> None:
self.head = head
self.body = body
self.column_widths: tuple[int] = tuple(self.find_widths())
def __str__(self) -> str:
return '\n'.join(self.lines())
def make_separator(self, left: str, mid: str, right: str) -> str:
header = mid.join(
'─' * width for width in self.column_widths
)
return f'{left}{header}{right}'
@staticmethod
def make_line(elements: Iterable[str]) -> str:
line = '│'.join(elements)
return f'│{line}│'
def lines(self) -> Iterator[str]:
yield self.make_separator(left='┌', mid='┬', right='┐')
yield self.make_line(
title.center(width)
for title, width in zip(self.head, self.column_widths)
)
yield self.make_separator(left='├', mid='┼', right='┤')
for row in self.body:
yield self.make_line(
element.ljust(width, ' ')
for element, width in zip(row, self.column_widths)
)
yield self.make_separator(left='└', mid='┴', right='┘')
def find_widths(self) -> Iterator[int]:
table = (*self.body, self.head)
for index in range(len(self.head)):
yield max(len(row[index]) for row in table)
def main() -> None:
names = (
'Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo',
'Foxtrot', 'Golf', 'Hotel', 'India', 'Juliet',
'Kilo', 'Lima', 'Mike', 'November', 'Oscar',
'Papa', 'Quebec', 'Romeo', 'Sierra', 'Tango',
'Uniform', 'Victor', 'Whiskey', 'X-Ray', 'Yankee',
'Zulu',
)
table1: Table = Table(
head=('Id', 'Name ', 'Marks'),
body=[
(
str(index).center(4),
name,
str(random.randint(0, 100)).rjust(5, ' ')
) for index, name in enumerate(names, 1)
],
)
print(table1)
if __name__ == '__main__':
main()
Third pass
Recognise that there is still some repetition: we're zipping over widths both times that we call the line-making utility function. Move that zip
to within the function, and cut away the formatting differences to their own functions, passing references to the functions into the line-making method. Also, you can splat a three-character string into your function arguments for shorter invocation.
from typing import Sequence, Iterator, Iterable, Callable
import random
"""
Unicode characters used:
Char Code Name
─ 2500 BOX DRAWINGS LIGHT HORIZONTAL
│ 2502 BOX DRAWINGS LIGHT VERTICAL
┌ 250C BOX DRAWINGS LIGHT DOWN AND RIGHT
┬ 252C BOX DRAWINGS LIGHT DOWN AND HORIZONTAL
┐ 2510 BOX DRAWINGS LIGHT DOWN AND LEFT
├ 251C BOX DRAWINGS LIGHT VERTICAL AND RIGHT
┼ 253C BOX DRAWINGS LIGHT VERTICAL AND HORIZONTAL
┤ 2524 BOX DRAWINGS LIGHT VERTICAL AND LEFT
└ 2514 BOX DRAWINGS LIGHT UP AND RIGHT
┴ 2534 BOX DRAWINGS LIGHT UP AND HORIZONTAL
┘ 2518 BOX DRAWINGS LIGHT UP AND LEFT
"""
class Table:
def __init__(self, head: Sequence[str], body: Sequence[Sequence[str]]) -> None:
self.head = head
self.body = body
self.column_widths: tuple[int] = tuple(self.find_widths())
def __str__(self) -> str:
return '\n'.join(self.lines())
def make_separator(self, left: str, mid: str, right: str) -> str:
line = mid.join(
'─' * width for width in self.column_widths
)
return f'{left}{line}{right}'
@staticmethod
def format_header(title: str, width: int) -> str:
return title.center(width)
@staticmethod
def format_element(element: str, width: int) -> str:
return element.ljust(width)
def make_line(
self,
elements: Iterable[str],
format: Callable[[str, int], str],
) -> str:
line = '│'.join(
format(element, width)
for element, width in zip(elements, self.column_widths)
)
return f'│{line}│'
def lines(self) -> Iterator[str]:
yield self.make_separator(*'┌┬┐')
yield self.make_line(self.head, self.format_header)
yield self.make_separator(*'├┼┤')
for row in self.body:
yield self.make_line(row, self.format_element)
yield self.make_separator(*'└┴┘')
def find_widths(self) -> Iterator[int]:
table = (*self.body, self.head)
for index in range(len(self.head)):
yield max(len(row[index]) for row in table)
def main() -> None:
names = (
'Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo',
'Foxtrot', 'Golf', 'Hotel', 'India', 'Juliet',
'Kilo', 'Lima', 'Mike', 'November', 'Oscar',
'Papa', 'Quebec', 'Romeo', 'Sierra', 'Tango',
'Uniform', 'Victor', 'Whiskey', 'X-Ray', 'Yankee',
'Zulu',
)
table1: Table = Table(
head=('Id', 'Name ', 'Marks'),
body=[
(
str(index).center(4),
name,
str(random.randint(0, 100)).rjust(5, ' ')
) for index, name in enumerate(names, 1)
],
)
print(table1)
if __name__ == '__main__':
main()
A word on Unicode
Though I'm still not convinced you should be using variable constants for your drawing characters, it would be informative and helpful to include a docstring like the one I showed at the top of the last code block.
Overall it's vaguely safe to use these characters for rendering in fixed-width terminals of modern machines. In narrow cases this might break; for interesting examples read Misalignment of Unicode block characters in preformatted text blocks.
When you carry this code and its output around editors, IDEs and source control, take care to preserve UTF-8 encoding. The output of the last sample seems byte-for-byte equivalent:
┌────┬────────┬─────┐
│ Id │ Name │Marks│
├────┼────────┼─────┤
│ 1 │Alpha │ 74│
│ 2 │Bravo │ 8│
│ 3 │Charlie │ 90│
│ 4 │Delta │ 75│
│ 5 │Echo │ 28│
│ 6 │Foxtrot │ 84│
│ 7 │Golf │ 92│
│ 8 │Hotel │ 38│
│ 9 │India │ 6│
│ 10 │Juliet │ 59│
│ 11 │Kilo │ 0│
│ 12 │Lima │ 85│
│ 13 │Mike │ 33│
│ 14 │November│ 81│
│ 15 │Oscar │ 39│
│ 16 │Papa │ 60│
│ 17 │Quebec │ 18│
│ 18 │Romeo │ 54│
│ 19 │Sierra │ 36│
│ 20 │Tango │ 50│
│ 21 │Uniform │ 53│
│ 22 │Victor │ 83│
│ 23 │Whiskey │ 85│
│ 24 │X-Ray │ 16│
│ 25 │Yankee │ 68│
│ 26 │Zulu │ 36│
└────┴────────┴─────┘
True Formatting
Your original approach (and all of the approaches above) rely on explicit string repetition through the *
operator. There is a very different approach that forms true formatting strings defining alignment, width and padding. For details on these parameters read the format mini-language specification.
Once this formatting string is defined, you can pre-bind to its .format()
method and hold a reference to that method. Since the formatting strings encode the column widths, you don't actually need to store the widths on the class and can drop them after the constructor.
I don't strongly consider this approach universally better or worse.
from typing import Sequence, Iterator, Iterable, Callable
import random
"""
Unicode characters used:
Char Code Name
─ 2500 BOX DRAWINGS LIGHT HORIZONTAL
│ 2502 BOX DRAWINGS LIGHT VERTICAL
┌ 250C BOX DRAWINGS LIGHT DOWN AND RIGHT
┬ 252C BOX DRAWINGS LIGHT DOWN AND HORIZONTAL
┐ 2510 BOX DRAWINGS LIGHT DOWN AND LEFT
├ 251C BOX DRAWINGS LIGHT VERTICAL AND RIGHT
┼ 253C BOX DRAWINGS LIGHT VERTICAL AND HORIZONTAL
┤ 2524 BOX DRAWINGS LIGHT VERTICAL AND LEFT
└ 2514 BOX DRAWINGS LIGHT UP AND RIGHT
┴ 2534 BOX DRAWINGS LIGHT UP AND HORIZONTAL
┘ 2518 BOX DRAWINGS LIGHT UP AND LEFT
"""
class Table:
def __init__(self, head: Sequence[str], body: Sequence[Sequence[str]]) -> None:
self.head = head
self.body = body
widths = tuple(self.find_widths())
self.format_sep = self.make_separator_format(widths)
self.format_head = self.make_content_format(widths, align='^')
self.format_body = self.make_content_format(widths, align='<')
def __str__(self) -> str:
return '\n'.join(self.lines())
@staticmethod
def make_separator_format(widths: Sequence[int]) -> Callable:
fmt = (
'{0}'
+ ''.join(
'{1:─>%d}' % (1 + width)
for width in widths[:-1]
)
+ '{2:─>%d}' % (1 + widths[-1])
)
return fmt.format
@staticmethod
def make_content_format(widths: Sequence[int], align: str) -> Callable:
fmt = (
'│'
+ '│'.join(
'{:%s%d}' % (align, width)
for width in widths
)
+ '│'
)
return fmt.format
def lines(self) -> Iterator[str]:
yield self.format_sep(*'┌┬┐')
yield self.format_head(*self.head)
yield self.format_sep(*'├┼┤')
for row in self.body:
yield self.format_body(*row)
yield self.format_sep(*'└┴┘')
def find_widths(self) -> Iterator[int]:
table = (*self.body, self.head)
for index in range(len(self.head)):
yield max(len(row[index]) for row in table)
def main() -> None:
names = (
'Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo',
'Foxtrot', 'Golf', 'Hotel', 'India', 'Juliet',
'Kilo', 'Lima', 'Mike', 'November', 'Oscar',
'Papa', 'Quebec', 'Romeo', 'Sierra', 'Tango',
'Uniform', 'Victor', 'Whiskey', 'X-Ray', 'Yankee',
'Zulu',
)
table1: Table = Table(
head=('Id', 'Name ', 'Marks'),
body=[
(
str(index).center(4),
name,
str(random.randint(0, 100)).rjust(5, ' ')
) for index, name in enumerate(names, 1)
],
)
print(table1)
if __name__ == '__main__':
main()
If you debug, you can see the format strings it makes:
{0}{1:─>5}{1:─>9}{2:─>6}
│{:^4}│{:^8}│{:^5}│
│{:<4}│{:<8}│{:<5}│
Formatting responsibility
Currently your main
function has stolen a little bit of the responsibility to format the cell content. This is a little awkward, and should just be transferred to the table class.
from typing import Sequence, Iterator, Callable, Optional, Any
from random import randint
"""
Unicode characters used:
Char Code Name
─ 2500 BOX DRAWINGS LIGHT HORIZONTAL
│ 2502 BOX DRAWINGS LIGHT VERTICAL
┌ 250C BOX DRAWINGS LIGHT DOWN AND RIGHT
┬ 252C BOX DRAWINGS LIGHT DOWN AND HORIZONTAL
┐ 2510 BOX DRAWINGS LIGHT DOWN AND LEFT
├ 251C BOX DRAWINGS LIGHT VERTICAL AND RIGHT
┼ 253C BOX DRAWINGS LIGHT VERTICAL AND HORIZONTAL
┤ 2524 BOX DRAWINGS LIGHT VERTICAL AND LEFT
└ 2514 BOX DRAWINGS LIGHT UP AND RIGHT
┴ 2534 BOX DRAWINGS LIGHT UP AND HORIZONTAL
┘ 2518 BOX DRAWINGS LIGHT UP AND LEFT
"""
class Table:
def __init__(
self,
head: Sequence[str],
body: Sequence[Sequence[Any]],
formats: Sequence[Optional[str]] = (),
) -> None:
self.head = head
self.body = tuple(self.format_body(body, formats))
widths = tuple(self.find_widths())
self.format_sep = self.make_separator_format(widths)
self.format_head = self.make_content_format(widths, align='^')
self.format_body = self.make_content_format(widths, align='<')
@staticmethod
def format_body(
body: Sequence[Sequence[Any]],
formats: Sequence[Optional[str]],
) -> Iterator[Sequence[str]]:
if not formats:
formats = (None,) * len(body[0])
for row in body:
yield [
fmt.format(cell) if fmt else str(cell)
for cell, fmt in zip(row, formats)
]
def __str__(self) -> str:
return '\n'.join(self.lines())
@staticmethod
def make_separator_format(widths: Sequence[int]) -> Callable:
fmt = (
'{0}'
+ ''.join(
'{1:─>%d}' % (1 + width)
for width in widths[:-1]
)
+ '{2:─>%d}' % (1 + widths[-1])
)
return fmt.format
@staticmethod
def make_content_format(
widths: Sequence[int],
align: str,
) -> Callable:
fmt = (
'│'
+ '│'.join(
'{:%s%d}' % (align, width)
for width in widths
)
+ '│'
)
return fmt.format
def lines(self) -> Iterator[str]:
yield self.format_sep(*'┌┬┐')
yield self.format_head(*self.head)
yield self.format_sep(*'├┼┤')
for row in self.body:
yield self.format_body(*row)
yield self.format_sep(*'└┴┘')
def find_widths(self) -> Iterator[int]:
table = (*self.body, self.head)
for index in range(len(self.head)):
yield max(len(row[index]) for row in table)
def main() -> None:
names = (
'Alpha', 'Bravo', 'Charlie', 'Delta', 'Echo',
'Foxtrot', 'Golf', 'Hotel', 'India', 'Juliet',
'Kilo', 'Lima', 'Mike', 'November', 'Oscar',
'Papa', 'Quebec', 'Romeo', 'Sierra', 'Tango',
'Uniform', 'Victor', 'Whiskey', 'X-Ray', 'Yankee',
'Zulu',
)
table1: Table = Table(
head=('Id', 'Name ', 'Marks'),
body=[
(index, name, randint(0, 100))
for index, name in enumerate(names, 1)
],
formats=('{:^4}', None, '{:>5}'),
)
print(table1)
if __name__ == '__main__':
main()
prettytable
and itsSINGLE_BORDER
style? \$\endgroup\$