2
\$\begingroup\$

Some of you might remember my previous implementations of this idea in C++. The threads can be found here and here. I tried to implemented the same idea, but this time in python.

A summary of the concept again: The idea is to organize data nicely in a table structure that is fully console-based but still highly customizable so that it allows to insert, edit, sort and delete data in a convenient manner.

For example input data like this:

["Germany", "Berlin", "82800000"]
["South Korea", "Seoul", "51446201"]
["China", "Beijing", "1403500365"]
["France", "Paris", "67201000"]
["Australia", "Canberra", "24877800"]
["Netherlands", "Amsterdam", "17200671"]
["Iceland", "Reykjavik", "348580"]

would be structured like this using the DataTable class:

+---------------+-------------+--------------+
|  Country      |  Capital    |  Population  |
+---------------+-------------+--------------+
|  Germany      |  Berlin     |  82800000    |
|  South Korea  |  Seoul      |  51446201    |
|  China        |  Beijing    |  1403500365  |
|  France       |  Paris      |  67201000    |
|  Australia    |  Canberra   |  24877800    |
|  Netherlands  |  Amsterdam  |  17200671    |
|  Iceland      |  Reykjavik  |  348580      |
+---------------+-------------+--------------+

DataTable.py

class DataTable:

    """
    Defines the padding between the text and each cell in the table
    """
    padding = 2

    """
    Holds the width of the widest entry of each column in the table
    """
    column_widths = []

    """
    Holds the description for each column header
    """
    table_headers = []

    """
    Holds all the rows and their information
    """
    table_data = []

    def __init__(self, table_headers, cell_padding):
        """
        Creates a new data table of a given size.
        :param table_headers: Array of table headers
        :param cell_padding: The padding between the cell text and the cell border
        """
        self.table_headers.append(table_headers)
        self.calculate_widths()
        self.padding = cell_padding

    def calculate_widths(self):
        """
        Calculate the correct width to the row and cell borders based on the content length in the data table.
        :return: None
        """
        # If no headers exist it is not possible to calculate the table widths
        if len(self.table_headers) == 0:
            raise Exception("Header required to calculate widths")

        # If no column widths were set initialize them using the header text width
        if len(self.column_widths) == 0:
            for i in range(0, len(self.table_headers[0])):
                self.column_widths.append(len(self.table_headers[0][i]))

        # If the table has no rows the header defines the widths
        for i in range(0, len(self.table_headers[0])):
            self.column_widths[i] = len(self.table_headers[0][i])  # check if [0] is right thing to use here

        # If the table has rows check all for larger widths then the current one
        for i in range(0, len(self.table_data)):
            for j in range(0, len(self.table_data[i])):
                if len(self.table_data[i][j]) > self.column_widths[j]:
                    self.column_widths[j] = len(self.table_data[i][j])

    def add_row(self, values):
        """
        Adds a row to the data table.
        :param values: Array of row values, count must be equal to data table widths
        :return: None
        """
        # Ensure correct amount of values is provided to fill exactly one table row
        if len(values) != len(self.table_headers[0]):
            raise Exception("Value count doesn't match table dimensions")
        self.table_data.append(values)
        self.calculate_widths()

    def insert_row(self, index, values):
        """
        Insert a row into the data table at a specific position
        :param index: The index in the table where the row gets inserted
        :param values: Array of row values, count must be equal to data table widths
        :return: None
        """
        # Ensure correct amount of values is provided to fill exactly one table row
        if len(values) != len(self.table_headers[0]):
            raise Exception("Value count doesn't match table dimensions")
        self.table_data.insert(index, values)
        self.calculate_widths()

    def edit_row(self, row_index, column_index, value):
        """
        Edit a field in a row in the data table
        :param row_index: The index of the row that should be edited
        :param column_index: The position of the cell in the selected row that should be edited
        :param value: The new value the cell will be updated with
        :return: None
        """
        # Ensure row_index is valid
        if row_index < 0 or row_index > len(self.table_data):
            raise Exception("Row index out of range")

        # Ensure column index is valid
        if column_index < 0 or column_index > len(self.table_data[0]):
            raise Exception("Column index out of range")

        self.table_data[row_index][column_index] = value
        self.calculate_widths()

    def delete_row(self, row_index):
        """
        Removes a row from the data table
        :param row_index: The index of the row that should be deleted
        :return: None
        """
        if row_index < 0 or row_index > len(self.table_data):
            raise Exception("Row index out of range")
        del self.table_data[row_index]
        self.calculate_widths()

    def edit_header(self, column_index, value):
        """
        Edit one of the header columns of the data table
        :param column_index: The index of the column that should be edited
        :param value: The new value that should be written in the selected header field
        :return: None
        """
        self.table_headers[0][column_index] = value
        if len(self.table_headers[0][column_index]) > self.column_widths[column_index]:
            self.column_widths[column_index] = len(self.table_headers[0][column_index])
        self.calculate_widths()

    def print_separator(self):
        """
        Generates a separator line that fits the table width
        :return: The separator line
        """
        output = "+"
        for i in range(0, len(self.column_widths)):
            output += ("-" * self.column_widths[i]) + ("-" * 2 * self.padding) + "+"
        return output

    def sort_table(self, column_index, descending):
        """
        Sorts the data table based on a column specified by the column index
        :param column_index: The index of the column that should be used for sorting
        :param descending: If true the table will be sorted in descending order, otherwise in ascending order
        :return: None
        """
        self.table_data.sort(key=lambda x: x[column_index], reverse=descending)

    def print_header(self):
        """
        Generates the table header of the data table formatted for printing
        :return:
        """
        output = ""
        for i in range(0, len(self.table_headers[0])):
            text = self.table_headers[0][i]
            diff = self.column_widths[i] - len(self.table_headers[0][i])
            output += ("|" + " " * self.padding + text + diff * " " + " " * self.padding)
        return output + "|"

    def print_rows(self):
        """
        Prints all rows and the intersecting separators of the data table
        :return: None
        """
        output = ""
        for j in range(0, len(self.table_data)):
            for i in range(0, len(self.table_data[j])):
                text = self.table_data[j][i]
                diff = self.column_widths[i] - len(self.table_data[j][i])
                output += ("|" + " " * self.padding + text + diff * " " + " " * self.padding)
            if j != len(self.table_data) - 1:
                output += "|\n"
            else:
                output += "|"
        return output

    def display(self):
        """
        Displays the formatted data table in text form
        :return: None
        """
        print(self.print_separator())
        print(self.print_header())
        print(self.print_separator())
        print(self.print_rows())
        print(self.print_separator())

Usage example:

from datatable import DataTable

table = DataTable(["Country", "Capital", "Population"], 2)

table.add_row(["Germany", "Berlin", "82800000"])
table.add_row(["South Korea", "Seoul", "51446201"])
table.add_row(["China", "Beijing", "1403500365"])
table.add_row(["France", "Paris", "67201000"])
table.add_row(["Australia", "Canberra", "24877800"])
table.add_row(["Netherlands", "Amsterdam", "17200671"])
table.add_row(["Iceland", "Reykjavik", "348580"])

# Delete a row
table.delete_row(3)

# Update cell with a new value
table.edit_row(1, 1, "SEOUL!!")

# Edit header
table.edit_header(2, "PEOPLE!!")

# Sort table alphabetically by capital
table.sort_table(1, False)

# Show the table
table.display()

Any bad practices or things that I could improve to make it more convenient to use? Also I am open for more functionality/features that could be added to this class.

\$\endgroup\$
2
\$\begingroup\$

I think the best designs come from usage. And your usage could be far simpler:

from datatable import DataTable

padding = 2
header = ["Country", "Capital", "Population"]

table = [
    ["Germany", "Berlin", "82800000"],
    ["South Korea", "Seoul", "51446201"],
    ["China", "Beijing", "1403500365"],
    ["France", "Paris", "67201000"],
    ["Australia", "Canberra", "24877800"],
    ["Netherlands", "Amsterdam", "17200671"],
    ["Iceland", "Reykjavik", "348580"]
]

# Delete a row
del table[3]

# Update cell with a new value
table[1][1] = "SEOUL!!"

# Edit header
header[2] = "PEOPLE!!"

# Sort table alphabetically by capital
table = sorted(table, key=lambda r: r[1])

# Show the table
t = DataTable(header, padding)
for row in table:
    t.add_row(row)
t.display()

This shows the most complicated aspect is interfacing with your code. And it shows that the majority of your code is not needed. To simplify the "show the table" section you can make your code a function. And so the following is all the code we need to know, so that we can create the function.

class DataTable:

    """
    Defines the padding between the text and each cell in the table
    """
    padding = 2

    """
    Holds the width of the widest entry of each column in the table
    """
    column_widths = []

    """
    Holds the description for each column header
    """
    table_headers = []

    """
    Holds all the rows and their information
    """
    table_data = []

    def __init__(self, table_headers, cell_padding):
        """
        Creates a new data table of a given size.
        :param table_headers: Array of table headers
        :param cell_padding: The padding between the cell text and the cell border
        """
        self.table_headers.append(table_headers)
        self.calculate_widths()
        self.padding = cell_padding

    def calculate_widths(self):
        """
        Calculate the correct width to the row and cell borders based on the content length in the data table.
        :return: None
        """
        # If no headers exist it is not possible to calculate the table widths
        if len(self.table_headers) == 0:
            raise Exception("Header required to calculate widths")

        # If no column widths were set initialize them using the header text width
        if len(self.column_widths) == 0:
            for i in range(0, len(self.table_headers[0])):
                self.column_widths.append(len(self.table_headers[0][i]))

        # If the table has no rows the header defines the widths
        for i in range(0, len(self.table_headers[0])):
            self.column_widths[i] = len(self.table_headers[0][i])  # check if [0] is right thing to use here

        # If the table has rows check all for larger widths then the current one
        for i in range(0, len(self.table_data)):
            for j in range(0, len(self.table_data[i])):
                if len(self.table_data[i][j]) > self.column_widths[j]:
                    self.column_widths[j] = len(self.table_data[i][j])

    def print_separator(self):
        """
        Generates a separator line that fits the table width
        :return: The separator line
        """
        output = "+"
        for i in range(0, len(self.column_widths)):
            output += ("-" * self.column_widths[i]) + ("-" * 2 * self.padding) + "+"
        return output

    def print_header(self):
        """
        Generates the table header of the data table formatted for printing
        :return:
        """
        output = ""
        for i in range(0, len(self.table_headers[0])):
            text = self.table_headers[0][i]
            diff = self.column_widths[i] - len(self.table_headers[0][i])
            output += ("|" + " " * self.padding + text + diff * " " + " " * self.padding)
        return output + "|"

    def print_rows(self):
        """
        Prints all rows and the intersecting separators of the data table
        :return: None
        """
        output = ""
        for j in range(0, len(self.table_data)):
            for i in range(0, len(self.table_data[j])):
                text = self.table_data[j][i]
                diff = self.column_widths[i] - len(self.table_data[j][i])
                output += ("|" + " " * self.padding + text + diff * " " + " " * self.padding)
            if j != len(self.table_data) - 1:
                output += "|\n"
            else:
                output += "|"
        return output

    def display(self):
        """
        Displays the formatted data table in text form
        :return: None
        """
        print(self.print_separator())
        print(self.print_header())
        print(self.print_separator())
        print(self.print_rows())
        print(self.print_separator())
  1. calculate_widths can be simplified by:

    1. Join the headers to the rows for the following calculations.
    2. Rotate the table by using zip.
    3. Change all the values to their length.
    4. Find the longest via max.

    This will result in a 1d list of the maximum, and doesn't require any headers.

  2. print_separator can be simplified by:

    1. Using a list comprehension and str.join.
    2. Calculating the amount of "-" characters together.
  3. print_header and print_rows can use the same underlying function, differentiated by the input you give it. And so I'm ignoring print_header.

  4. print_rows can be simplified by:
    1. Performing the separation of each row with a newline outside the function. And make the function yield each row.
    2. Looping Pythonically through data via for row in self.table_data, rather than through a range and indexing the data.
    3. You can use str.format to pad text to a width.

To make the changes I made to each function very apparent below is what they look like after the changes, but before making everything a single function.

class DataTable:
    padding = 2
    column_widths = []
    table_headers = []
    table_data = []

    def __init__(self, table_headers, cell_padding):
        self.table_headers.append(table_headers)
        self.padding = cell_padding

    def calculate_widths(self):
        table = []
        if self.table_headers:
            table += [self.table_headers]
        table += self.table_data
        table = zip(*table)
        self.column_widths = [max(len(t) for t in row) for row in table]


    def print_separator(self):
        row = ["-" * (width + 2 * self.padding) for width in self.column_widths]
        return "+{}+".format("+".join(row))

    def print_rows(self, data):
        for row in data:
            row = [
                "|{p}{text: <{width}}{p}|".format(
                    text=text,
                    width=width,
                    p=" " * self.padding
                )
                for text, width in zip(row, self.column_widths)
            ]
            yield "|{}|".format("|".join(row))

    def display(self):
        self.calculate_widths()
        print(self.print_separator())
        print(next(self.print_rows([self.table_headers])))
        print(self.print_separator())
        print("\n".join(self.print_rows(self.table_data)))
        print(self.print_separator())

After this I'd change the code to a couple of functions:

def _calculate_widths(data, header):
    table = []
    if header:
        table += [header]
    table += data
    table = zip(*table)
    return [max(len(t) for t in row) for row in table]


def _pretty_rows(data, padding, widths):
    for row in data:
        yield "|".join(
            ['']
            + [
                "|{p}{text: <{width}}{p}|".format(
                    text=text,
                    width=width,
                    p=" " * padding
                )
                for text, width in zip(row, widths)
            ]
            + ['']
        )


def _pretty_table(data, padding, header):
    widths = _calculate_widths(data, header)
    seperator = "+".join(
        [""]
        + ["-" * (width + 2 * padding) for width in widths]
        + [""]
    )
    if header:
        yield seperator
        yield from _pretty_rows([header], padding, widths)
    yield separator
    yield from _pretty_rows(rows, padding, widths)
    yield seperator


def pretty_table(data, *, padding=2, header=None):
    return '\n'.join(_pretty_table(
        data=data,
        padding=padding,
        header=header
    ))
# Show the table
print(pretty_table(table, header=header, padding=padding))
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

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