Skip to content

Solution du problème ASCII_Array

Vous trouverez ici la solution pour l'exercice suivant :

ASCII_Table

Exercice de base

Créons une classe pour notre table. Elle devra prendre un parametre pour le constructeur: les données pour remplir notre tableau:

class Table:
    def __init__(self, content):
        self.content = content

C'est une question de taille

Afin de calculer la représentation en chaine de caractères il va nous falloir calculer la largeure de chaque colonne. Il va falloir créer une version par colonne de notre contenu qui est donné par ligne:

class Table:
    def __init__(self, content):
        self.content = content

    def compute_column_sizes(self):
        by_column = list()
        for item in self.content[0]:
            by_column.append(list())
        for row in self.content:
            for i, v in enumerate(row):
                by_column[i].append(len(str(v)))
        self.column_sizes = list()
        for col in by_column:
            self.column_sizes.append(max(col))

Nous pouvons réécrire cette methode en utilisant la comprehention de liste:

    def compute_column_sizes(self):
        return [
            max(map(len, map(str, col)))
            for col in zip(*self.content)
        ]

Attardons nous sur cette solution, tout d'abord, nous devons traiter les données colonne par colonne alors que nos données sont ligne par ligne. Pour cela nous utilisons zip(*self.content). En effet appeler zip permet de faire des tuples a partir de plusieurs listes en prennant un element à chaque fois dans chaque liste. En utilisant l'opérateur * sur notre contenu, c'est comme si nous avions donné chacune de ses listes qui la compose à la fonction zip().

Nous obtenons donc ainsi la transposée de notre liste de lignes, c'est à dire une liste de colonnes.

Mais ce n'est pas fini, nous avons besoin de connaitre la taille maximale des éléments de chacune de colonnes. Pour cela on utilisera map. map permet d'appliquer une trnasformation sur chacun des elements d'un iterable, en retournant elle même un iterable.

map(str, my_list) retournera donc un iterateur sur tous les elements de my_list convertis en string. Si on appelle map(len, ) sur le resultat precedent, on obtient donc un generateur sur la taille des strings des elements de la liste d'origine.

Il nous suffit donc de calculer le maximum de chacunes de ces tailles pour obtenir la taille à utiliser pour chacune de nos colonnes.

Ou un problème de représentation

Pour faire en sorte que notre objet soit "convertible" en chaine de caractères il existe 2 dunder méthodes : * __str__ : méthode appelée quand on appelle str() * __repr__ : méthode appelée quand on appelle repr() (mais aussi str() si __str__ n'est pas redéfinie)

Compte tenu des spécifications de notre exercice il faudra redefinir les 2 fonctions pour 2 résultats differents entre str() et repr().

La plus simple des deux étant repr(), commençons par celle la.

repr

Pour celle la il suffit d'obtenir la taille de la table, et d'injecter dans une f-string:

    def __repr__(self):
        return f"Table(rows={len(self.content)}, cols={len(self.content[0])})"

Note

Oui ceci n'est pas 100% sur, si on a une table vide par exemple. Pour proteger cela il suffira de mettre le tout dans un bloc try, le comportement n'étant pas précisé, je m'abstiendrais de le faire.

str

Là nous entrons dans le vif du sujet. Il va donc falloir faire en sorte de mettre des espace pour que chaque colonne ai la bonne taille.

Pour cela Python nous met a disposition une fonction pour les chaines de caractères: rjust(). On donne le nombre de caractères que l'on veut, et optionellement le caractère a utiliser pour "remplir" les trous.

>>> "a".rjust(5)
'    a'

Nous allons donc avoir besoin de nos contenus de cellule sous forme de string:

    def __str__(self):
        as_string = list()
        for row in self.content:
            as_string.append(list(map(str, row)))
        ...

Pour la suite, il va falloir imprimer les lignes avec leurs colones de la bonne taille. Ce qui sera facile avec zip() pour itérer sur chaque ligne avec la size et le contenu de la cellule:

    def __str__(self):
        as_string = list()
        for row in self.content:
            as_string.append(list(map(str, row)))
        sizes = self.compute_column_sizes()
        result = ""
        for row in as_string:
            row_items = [
                cell.rjust(s)
                for s, cell in zip(size, row)
            ]
            result += f"| {' | '.join(row_items)} |\n"
        return result.rstrip()
' | '.join(row_items) permet "coller" les éléments de row_items en mettant ' | ' entre chacun d'eux:
>>> ",".join((1, 2, 3))
'1,2,3'

Et pour la dernière ligne nous devons enlever le retour chariot (\n) en trop.

Il reste un problème: il nous faut une ligne de - après la première ligne du tableau. Pour cela il faudra rajouter une ligne ainsi:

   def __str__(self):
        as_string = list()
        for row in self.content:
            as_string.append(list(map(str, row)))
        sizes = self.compute_column_sizes()
        result = ""
        for idx, row in enumerate(as_string):
            row_items = [
                cell.rjust(s)
                for s, cell in zip(size, row)
            ]
            result += f"| {' | '.join(row_items)} |\n"
            if idx == 0:
                row_items = [
                    "-" * s
                    for s in size
                ]
                result += f"| {' | '.join(row_items)} |\n"
        return result.rstrip()

Et voila.

Support des générateurs

Dernier petit soucis à regarder: la gestion des générateurs. Un générateur est un objet python qui est itérable, mais qu'on ne peut parcourir qu'une fois. range() est un très bon exemple de générateur:

>>> r = range(2)
>>> for i in in r:
...     print(i)
0
1
>>> for i in r:
...     print(i)

Donc si on nous passe un générateur comme entrée de notre classe, on ne pourra retourner qu'une seule fois sa représentation en string, mais pas plusieurs. (Dans la solution exposée jusque là c'est même pire: la requête de la taille des colonnes suffit à épuiser les éventuels générateurs).

Une seule solution, faire une copie de ce que nous avons en entrée. Est-ce grave? Et bien ... non, nous opérons sur des tableaux de relative petite taille (on est dans de la documentation; pas dans de l'opendata), donc faire une copie ne sera pas un problème. Bien au contraire, lorsque nous ferons la copie, nous pourrons même en profiter pour gagner du temps pour la suite, comme convertir les entrées en string, ce qui sera un gain de temps et de code.

Voici donc ma solution:

class Table(object):
    def __init__(self, content):
        self.content = [
            list(map(str, row))
            for row in content
        ]

    def compute_column_sizes(self):
        return [
            max(map(len, col))
            for col in zip(*self.content)
        ]

    def __str__(self):
        sizes = self.compute_column_sizes()
        result = ""
        for idx, row in enumerate(self.content):
            row_items = [
                x.rjust(i)
                for i, x in zip(sizes, row)
            ]
            result += f"| {' | '.join(row_items)} |\n"
            if idx == 0:
                row_items = [
                    "-" * i
                    for i in sizes
                ]
                result += f"| {' | '.join(row_items)} |\n"
        return result.strip()

    def __repr__(self):
        return f"Table(rows={len(self.content)}, cols={len(self.content[0])})"

Bonus 1

Il va nous falloir parser le contenu d'un string afin d'en faire un tableau. Nous savons déjà que nous devons itérer sur les lignes de notre entrée; en faisant sauter la seconde ligne (celle avec des "-"). Et nous savons que tous nos éléments sont séparés par des |. Donc pour chaque ligne il va falloir découper en utilisant | comme séparateur, et enlever les morceaux qui ne servent à rien (et oui, les lignes sont "bordées" par des |).

...
    def parse_string_table(self, content):
        result = list()
        for idx, line in enumerate(content.splitlines()):
            if idx == 1:
                # do not handle line filled with '-'
                continue
            result.append(
                [
                    x.strip()
                    for x in line.split("|")[1:-1]
                ]
            )
        return result

On peut voir ici que nous utilisons encore la compréhension de liste pour simplifier l'écriture: nous prenons du second à l'avant dernier élément de la liste découpée par "|", et nous appelons "strip()" dessus, afin de "nettoyer" l'élément de tous les caractères blancs (espace, etc) qu'il pourrait contenir.

Maintenant il ne nous reste plus qu'à injecter cette méthode dans le constructeur lorsqu'on crée une table avec un string en entrée.

J'aurai préféré faire du duck-typing mais je n'ai pas vraiment trouvé de façon élégante de faire, du coup je me rabaterai sur le questionnement du type de l'entrée. Voici donc ma solution:

class Table(object):
    def __init__(self, content):
        if type(content) is str:
            self.content = self.parse_string_table(content)
        else:
            self.content = [
                list(map(str, row))
                for row in content
            ]

    def compute_column_sizes(self):
        return [
            max(map(len, col))
            for col in zip(*self.content)
        ]

    def parse_string_table(self, content):
        result = list()
        for idx, line in enumerate(content.splitlines()):
            if idx == 1:
                # do not handle line filled with '-'
                continue
            result.append(
                [
                    x.strip()
                    for x in line.split("|")[1:-1]
                ]
            )
        return result

    def __str__(self):
        sizes = self.compute_column_sizes()
        result = ""
        for idx, row in enumerate(self.content):
            row_items = [
                x.rjust(i)
                for i, x in zip(sizes, row)
            ]
            result += f"| {' | '.join(row_items)} |\n"
            if idx == 0:
                row_items = [
                    "-" * i
                    for i in sizes
                ]
                result += f"| {' | '.join(row_items)} |\n"
        return result.strip()

    def __repr__(self):
        return f"Table(rows={len(self.content)}, cols={len(self.content[0])})"

Bonus 2

Pour pouvoir accéder aux éléments du tableau, il nous faut définir la dunder méthode __getitem__. Nous voulons soit accéder a une ligne (l'index passé est un chiffre) soit à une cellule (l'index passé est un tuple). Un peu de ducktyping plus tard nous arrivons au résultat suivant:

    def __getitem__(self, index):
        try:
            row, col = index
            return self.content[row][col]
        except TypeError:
            return self.content[index]

Nous essayons de décomposer l'index en 2 afin de pouvoir retourner l'élément. Mais si nous ne pouvons pas (TypeError exception) c'est qu'on nous a donné un entier comme index, et donc nous retournons la ligne correspondante.

Pour l'ecriture, c'est pareil, à ceci pré que nous ne voulons supporter QUE le mode "ecrire dans une cellule", donc on decompose l'index, et on oublie pas de stocker le str de la donnée car sinon notre méthode __str__ ne fonctionnera plus.

    def __setitem__(self, index, value):
        row, col = index
        self.content[row][col] = str(value)

Bonus 3

Il va falloir être méthodique ici, mais rien de bien dangereux en fait. Il faut réussir a trouver une colonne et une ligne dans notre tableau de cellules et cela ira très vite.

insert_row, append_row

Une fois le insert_row fait, le append_row est vite fait, il suffit de changer un appel.

    def insert_row(self, index, row):
        new_row = list(map(str, row))
        if len(new_row) != len(self.content[0]):
            raise ValueError(
                f"input row is not of the right size {len(new_row)} (should be {len(self.content[0])})"
            )
        self.content.insert(index, new_row)

    def append_row(self, row):
        new_row = list(map(str, row))
        if len(new_row) != len(self.content[0]):
            raise ValueError(
                f"input row is not of the right size {len(new_row)} (should be {len(self.content[0])})"
            )
        self.content.append(new_row)

insert_col, append_col

Ici aussi, une fois une version faite il est très simple d'avoir la seconde:

    def insert_col(self, index, col):
        new_col = list(map(str, col))
        if len(new_col) != len(self.content):
            raise ValueError(
                f"input column is not of the right size {len(new_col)} (should be {len(self.content)})"
            )
        for idx, item in enumerate(new_col):
            self.content[idx].insert(index, item)

    def append_col(self, col):
        new_col = list(map(str, col))
        if len(new_col) != len(self.content):
            raise ValueError(
                f"input column is not of the right size {len(new_col)} (should be {len(self.content)})"
            )
        for idx, item in enumerate(new_col):
            self.content[idx].append(item)

Il suffit d'iterer sur les lignes et d'ajouter l'element de la colonne qu'on nous a donné.

pop_row et pop_col

Enfin, les deux dernieres methodes. Comme nous stockons nos lignes dans un tableau, faire un poop n'est pas compliqué:

    def pop_row(self, index=-1):
        return self.content.pop(index)

Pour les collonnes il suffit de faire pareil ligne par ligne et de retourner la liste ainsi produite:

    def pop_col(self, index=-1):
        result = list()
        for row in self.content:
            result.append(row.pop(index))
        return result

On peut aussi faire bien mieux avec les comprehansion de listes:

    def pop_col(self, index=-1):
        return [
            row.pop(index)
            for row in self.content
        ]

Conclusion

Voila c'est fini, un petit exercice (un peu long) mais sans reelle complication, que des choses de base... Ma solution est donc la suivante:

class Table(object):
    def __init__(self, content):
        if type(content) is str:
            self.content = self.parse_string_table(content)
        else:
            self.content = [
                list(map(str, row))
                for row in content
            ]

    def compute_column_sizes(self):
        return [
            max(map(len, col))
            for col in zip(*self.content)
        ]

    def parse_string_table(self, content):
        result = list()
        for idx, line in enumerate(content.splitlines()):
            if idx == 1:
                # do not handle line filled with '-'
                continue
            result.append(
                [
                    x.strip()
                    for x in line.split("|")[1:-1]
                ]
            )
        return result

    def __str__(self):
        sizes = self.compute_column_sizes()
        result = ""
        for idx, row in enumerate(self.content):
            row_items = [
                x.rjust(i)
                for i, x in zip(sizes, row)
            ]
            result += f"| {' | '.join(row_items)} |\n"
            if idx == 0:
                row_items = [
                    "-" * i
                    for i in sizes
                ]
                result += f"| {' | '.join(row_items)} |\n"
        return result.strip()

    def __repr__(self):
        return f"Table(rows={len(self.content)}, cols={len(self.content[0])})"

    def __getitem__(self, index):
        try:
            row, col = index
            return self.content[row][col]
        except TypeError:
            return self.content[index]

    def __setitem__(self, index, value):
        row, col = index
        self.content[row][col] = str(value)

    def insert_row(self, index, row):
        new_row = list(map(str, row))
        if len(new_row) != len(self.content[0]):
            raise ValueError(
                f"input row is not of the right size {len(new_row)} (should be {len(self.content[0])})"
            )
        self.content.insert(index, new_row)

    def append_row(self, row):
        new_row = list(map(str, row))
        if len(new_row) != len(self.content[0]):
            raise ValueError(
                f"input row is not of the right size {len(new_row)} (should be {len(self.content[0])})"
            )
        self.content.append(new_row)

    def pop_row(self, index=-1):
        return self.content.pop(index)

    def insert_col(self, index, col):
        new_col = list(map(str, col))
        if len(new_col) != len(self.content):
            raise ValueError(
                f"input column is not of the right size {len(new_col)} (should be {len(self.content)})"
            )
        for idx, item in enumerate(new_col):
            self.content[idx].insert(index, item)

    def append_col(self, col):
        new_col = list(map(str, col))
        if len(new_col) != len(self.content):
            raise ValueError(
                f"input column is not of the right size {len(new_col)} (should be {len(self.content)})"
            )
        for idx, item in enumerate(new_col):
            self.content[idx].append(item)

    def pop_col(self, index=-1):
        return [
            row.pop(index)
            for row in self.content
        ]