5
\$\begingroup\$

The goal of the project is to take in input for a data, perhaps in the future as a csv, then print it out using UNICODE box-drawing characters.
The main function declares the necessary data and Table class contains all methods and data.

Code

from typing import List
import random


class Table:
    def __init__(self, head: List[str], body: List[List[str]]):
        self.head: List[str] = head
        self.body: List[List[str]] = body
        self.column_widths: List[int] = self.find_widths()
        # print(self.column_widths)

        # Table symbols
        self.top_right_corner = '┐'
        self.top_left_corner = '┌'
        self.bottom_right_corner = '┘'
        self.bottom_left_corner = '└'
        self.top_intersection = '┬'
        self.bottom_intersection = '┴'
        self.middle_intersection = '┼'
        self.left_intersection = '├'
        self.right_intersection = '┤'
        self.divider = '│'
        self.horizontal = '─'

    def print(self) -> None:
        # Top most portion
        print(f"┌{'┬'.join(['─' * self.column_widths[i] for i, _ in enumerate(self.head)])}┐")

        # Header of table
        print(f"│{'│'.join([title.center(self.column_widths[index]) for index, title in enumerate(self.head)])}│")

        # Header and body separator
        print(f"├{'┼'.join(['─' * self.column_widths[i] for i, _ in enumerate(self.head)])}┤")

        # Body
        for row in self.body:
            print("│", end='')
            for index, element in enumerate(row):
                print(element.ljust(self.column_widths[index], ' '), end='│')
            print()

        # Tail of table
        print(f"└{'┴'.join(['─' * self.column_widths[i] for i, _ in enumerate(self.head)])}┘")

    def find_widths(self) -> List[int]:
        list_of_widths: List[int] = []
        table: List[List[str]] = self.body[:]
        table.append(self.head)
        for index, _ in enumerate(self.head):
            list_of_widths.append(max([len(row[index]) for row in table]))
        return list_of_widths


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 + 1).center(4),
                                 name,
                                 str(random.randint(0, 100)).rjust(5, ' ')] for index, name in enumerate(names)])
    table1.print()


if __name__ == "__main__":
    main()

Output

┌────┬────────┬─────┐
│ Id │ Name   │Marks│
├────┼────────┼─────┤
│ 1  │Alpha   │   64│
│ 2  │Bravo   │   74│
│ 3  │Charlie │   46│
│ 4  │Delta   │   41│
│ 5  │Echo    │   18│
│ 6  │Foxtrot │   84│
│ 7  │Golf    │   52│
│ 8  │Hotel   │   72│
│ 9  │India   │   96│
│ 10 │Juliet  │   52│
│ 11 │Kilo    │   27│
│ 12 │Lima    │   91│
│ 13 │Mike    │   68│
│ 14 │November│   84│
│ 15 │Oscar   │   68│
│ 16 │Papa    │   45│
│ 17 │Quebec  │   27│
│ 18 │Romeo   │   17│
│ 19 │Sierra  │   64│
│ 20 │Tango   │   37│
│ 21 │Uniform │   42│
│ 22 │Victor  │   99│
│ 23 │Whiskey │   46│
│ 24 │X-Ray   │   34│
│ 25 │Yankee  │    5│
│ 26 │Zulu    │   13│
└────┴────────┴─────┘

Comments

Please suggest any improvements I can make to the code.

\$\endgroup\$
7
  • \$\begingroup\$ Great first question. \$\endgroup\$
    – Reinderien
    May 15, 2022 at 13:53
  • \$\begingroup\$ Why does the table look jagged on my phone? More specifically, in the first, second and last lines, the vertical bars are shifted to the right of where they should be, and the symbols are longer than they should be. Whose fault is this? \$\endgroup\$ May 15, 2022 at 14:59
  • \$\begingroup\$ @ΞένηΓήινος My best guess is that your phone is doing a poor job of rendering the unicode characters with constant width. This looks fine in Linux. \$\endgroup\$
    – Reinderien
    May 15, 2022 at 15:09
  • \$\begingroup\$ @ΞένηΓήινος Read e.g. this post which seems to agree with you that Unicode width rendering is a crapshoot. \$\endgroup\$
    – Reinderien
    May 15, 2022 at 15:14
  • 1
    \$\begingroup\$ Are you aware of prettytable and its SINGLE_BORDER style? \$\endgroup\$ May 15, 2022 at 19:42

1 Answer 1

4
\$\begingroup\$

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()
\$\endgroup\$
2
  • 2
    \$\begingroup\$ Thank you so much for your input. The addition of generators and static methods really made the code much better and can't wait to improve my other projects with the same. I had kept variable constants for unicode characters in the hope of being able to have multiple configurations. I will look for a better way to solve that problem. \$\endgroup\$
    – Saphereye
    May 15, 2022 at 16:36
  • 1
    \$\begingroup\$ @Saphereye Sure. Note that in the last alternative implementation, the statics have gone away and have been replaced by dynamically-generated format strings. \$\endgroup\$
    – Reinderien
    May 15, 2022 at 16:51

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.