Le Script Python en Ligne de Commande

Posté le 28-10-2023 par Aliaume Lopez – lecture en 12 minutes (≈ 2837 mots)
Partie 3/3 d'une série de posts sur UI and UX for small programs
1.

C’est-à-dire, Libre Office Calc, si vous n’avez pas suivi, c’est le point de référence qui servira à l’évaluation du programme proposé dans cet article.

Dans l’épisode précédent, le logiciel Libre Office Calc avait été utilisé (avec succès) pour facilement ajouter des tags sur des lignes dans un fichier CSV, et en extraire rapidement un résumé. Dans cet article, je propose de passer d’une solution utilisant un logiciel généraliste11 à un “logiciel métier” (où progiciel) avec les objectifs suivants :

  1. Conserver autant que possible les avantages proposés par l’approche utilisant un tableur (possibilité d’interruption, sauvegarde, édition non-linéaire, complétion, etc.)
  2. Résoudre les problèmes posés par l’interface (l’espace “efficace” est ridicule, les dates ne sont pas faciles à comprendre, les nombres ne sont pas toujours bien pris en compte, etc.)

Le résultat, visible dans le asciinema ci-après, est relativement satisfaisant, et déjà utilisable pour des petites quantités de lignes (< 100). Le code est disponible sur GitHub.

Création de l’invite de commande

Le programme se découpe en trois phases, mimant celles utilisées dans l’approche par tableur. Dans une première phase d’ouverture du fichier CSV, l’objectif est d’obtenir une “vue” du fichier qui corresponde à ce que l’utilisateur désire. Dans la seconde phase, l’utilisateur peut ajouter des annotations aux différentes lignes, de manière non-linéaire. Enfin, dans une troisième phase, l’utilisateur obtient un résumé succinct de son annotation.

Les deux premières phases sont, abstraitement, un calcul de point fixe : l’utilisateur va modifier des paramètres jusqu’à ce qu’un état satisfaisant (fixe) soit obtenu. La dernière phase est plus classique et consiste juste à afficher des données, sans aucune interaction utilisateur. Le code total fait moins de \(450\) lignes de python complètement standard, qui se permet le luxe d’un petit nombre de commentaires.

Récupération du CSV

La première phase du programme dans son utilisation standard est l’ouverture d’un fichier CSV à annoter. Le développement de ce logiciel suit une approche data-driven, et la première chose à faire est de spécifier quelles sont les données en entrée et en sortie de cette phase. L’entrée est assez raisonnable, puisque c’est simplement un chemin vers le fichier CSV path : Path. La sortie doit être une version structurée de ce fichier, et sans trop grande ambition, on arrive à la définition suivante :

class CSVFile(BaseModel):
    content: List[List[str]] # le tableau représenté par le CSV
    start:   int             # la ligne à partir de laquelle il faut lire les données
    path:    Path            # le chemin du fichier CSV originel
2.

Cela ne veut pas dire qu’il est facile à décoder ! Il existe une liste de préconceptions (fausses) à propos du format CSV. De manière amusante, c’est aussi vrai pour la gestion des dates.

Notons que cette approche est minimaliste. Le format CSV ne spécifie pas énormément de choses,22 et en particulier on peut imaginer avoir des lignes “superflues” (en-têtes) au début du fichier. Même cela présuppose que le fichier est dans un format raisonnable, ce qui peut très bien ne pas correspondre à un type spécifique de fichier CSV.

Au cœur de l’ouverture du fichier CSV on trouve cette procédure interactive_parse_csv(p: Path) -> CSVFile. Celle-ci est définie par un point fixe à partir de la fonction csv.reader du module csv. Abstraitement, csv.reader, en plus du fichier CSV, prend un certain nombre d’arguments \(\mathsf{CsvOpts}\) permettant de lire le fichier (le plus évident étant le choix du caractère séparateur des lignes). On peut donc voir la fonction csv.reader comme une fonction \(f \colon \mathsf{Path} \times \mathsf{CsvOpts}\to \mathsf{CsvFile}\), ce qui est plus ou moins le rôle de la fonction d’emballage parse_csv dans le code. L’utilisateur peut alors être modélisé comme une fonction \(u \colon \mathsf{CsvFile}\to \mathsf{CsvOpts}\), car il peut corriger les paramètres en fonction de ce qu’il voit affiché. Ainsi, le chemin \(p\) étant fixé, le programme essaie de trouver un point fixe \(c\) de la fonction \(u \circ f(p, \cdot)\), et retourne simplement \(f(p, c)\). À la fin des fins, \(\text{interactiveParseCsv}(p) = f(p, \mu c. u(f(p,c)))\), et c’est précisément ce que fait le code (avec une boucle while pour calculer le point fixe).

Illustration graphique du calcul de point fixe.

À ces considérations théoriques structurant le programme, on peut ajouter quelques remarques plus pratiques qui améliorent considérablement l’expérience utilisateur (et ce à moindre coût). Déjà, le code tel que proposé commence par calculer \(f(p,c)\) avec un \(c\) initial correspondant à \(90\%\) des cas d’usages (le séparateur est une virgule, la première ligne est une ligne d’en-têtes, etc.). C’est définitivement ce qu’il faut faire, puisque cela veut dire que \(90\%\) du temps, l’utilisateur à juste à dire “oui”. Ensuite, l’utilisateur va avoir un aperçu de ce qui est compris du fichier CSV avec les paramètres actuels : il faut lui donner plusieurs lignes de références, le nombre de colonnes et quelques informations permettant de détecter instantanément si les données seront bien valides. Enfin, lorsqu’on demande à modifier les paramètres (dans le cas où l’utilisateur désire les changer), considérer par défaut que l’utilisateur veut les garder identiques et permettre de simplement utiliser la touche ENTRÉE pour passer à la modification suivante. Souvent, il suffit de faire varier un unique paramètre pour corriger le comportement du programme, et cela simplifie beaucoup le travail de l’utilisateur de penser uniquement à ce qui doit changer, et pas à l’intégralité de la configuration.

Création des colonnes

Une fois que le programme a bien découpé le fichier CSV en une matrice, il reste encore à comprendre ce que veulent dire les cases de cette matrice. Pour cela, on veut remplacer les lignes de la matrice par une structure de données plus riche, qui correspond à l’image mentale d’une liste de dépenses :

class CSVLine(BaseModel):
    content: List[str]                # la ligne originale
    dates:   Dict[str, datetime.date] # les dates présentes
    tag:     Optional[str]            # l’annotation proposée
    infos:   Dict[str, str]           # des colonnes informatives
    debit:   float                    # le débit (négatif)
    credit:  float                    # le crédit (positif)

Pour passer d’une ligne de notre matrice actuelle, de type List[str] à une telle structure, il nous suffit d’avoir une table de traduction (aussi appelée “mapping”) que l’on peut formaliser comme ci-après :

class CSVMapping(BaseModel):
    dates:  Dict[str, int] # quelles colonnes ont des dates
    tag:    int            # quelle colonne contient le tag
    infos:  Dict[str, int] # quelles colonnes contiennent des informations
    debit:  int            # quelle est la colonne de débit
    credit: int            # quelle est la colonne de crédit

Notons qu’on aurait pu choisir une représentation différente, qui donne plutôt une liste List[Type] associant à chaque indice un type de case. On aurait alors eu un type Date, un type Credit, un type Debit, etc. Cette approche, implicitement, suppose que le CSVMapping ci-dessus représente une fonction, partant des indices du fichier CSV, et arrivant aux différents types de données. C’est absolument faux. Il est possible que le CSV contienne une seule colonne pour le débit et le crédit, ou que certaines cases servent dans plusieurs parties de notre structure de ligne, et d’ailleurs, une grande partie des colonnes d’un fichier CSV bien fourni sont inutiles lors de l’annotation.

3.

On peut remarquer que dans les structures de données choisies, on garde souvent “trop” d’informations. C’est inutile jusqu’à ce que cela devienne indispensable. En effet, garder les lignes “non lues” et garder la version du logiciel ayant produit cet état permet de sérialiser (dans un fichier par exemple) l’état du programme sans trop se poser de questions.

Exactement comme pour la lecture du CSV, on peut demander à l’utilisateur d’écrire son CSVMapping pour produire une structure de donnée plus facile à traiter définie comme suit :33

class State(BaseModel):
    version:  str              # la version actuelle du programme
    cursor:   int              # la position de l’utilisateur dans le CSV
    mapping:  CSVMapping       # le mapping défni par l’utilisateur
    data:     List[CSVLine]    # le fichier CSV "interprété"
    unparsed: List[List[str]]  # le fichier CSV "originel"

Annotations !

De manière amusante, la partie la moins intéressante techniquement est l’annotation. En effet, il s’agit de nouveau d’un calcul de point fixe dont l’état est représenté par State. Là où l’annotation devient intéressante c’est sur les décisions ergonomiques qui lui sont associées. Le code de la fonction de modification est le suivant :

def interactive_modify(s : State, p : Path):
    while True:
        print("")
        print_summary(s)
        print_line_calendar(s)
        print_line_summary(s)
        print_line_balance(s)
        print_line_status(s)
        i_action = input("Action: ")
        if i_action == "q" or i_action == "quit":
            break
        action = actions.get(i_action, update_tag(i_action))
        action(s)
        write_state(s, p)

Typiquement, on affiche un certain nombre d’informations à l’utilisateur, avant d’attendre une commande de sa part (et recommencer). Notons que l’état est enregistré sur le disque à chaque étape, permettant de se prémunir contre toute interruption inopportune dans le processus d’annotation. Ce que voit l’utilisateur est typiquement découpé en quatre parties.

  1. Une première partie informe sur la position dans le document et l’état général du document. C’est répondre à “où suis-je”. L’affichage choisi est le suivant, où la première ligne sert à indiquer combien d’annotations sont effectuées et la seconde donne une représentation graphique de la position dans la liste (entre crochets) ainsi que des annotations posées (croisillons).

    N: 13 / 15. 2 items skipped.
    ####_#_###[#]####
  2. Une seconde partie informe sur les dates présentes dans la dépense en cours d’examen. C’est un calendrier (ou deux) avec des dates en surbrillance.

         October 2024
     Mo Tu We Th Fr Sa Su
         1  2  3  4  5  6
      7  8  9 10 11 12 13
     14 15 16 17 18 19 20
     21 22 23 24 25 26 27
     28 29 30 31
  3. Une troisième partie affiche de manière compacte les informations non temporelles associées à la dépense courante.

      [Libelle operation]     VIR FACT PharmacieBaguette
      [Libelle simplifie]     FACT PharmacieBaguette
      [Categorie]             Vacances
      [Sous categorie]        -
                              +0.00 / -42.75 	 42.75
      [tag]                   pharma
  4. Enfin, une dernière partie de l’affichage récupère l’entrée utilisateur.

     Action:

Cet affichage est largement plus compact que son correspondant côté tableur et l’interface est globalement plus efficace. En particulier, il n’y a pas d’information superflue (boutons, options, autres lignes), tout en gardant une capacité à rapidement se repérer dans le tableau. Le tout est doublé d’un choix d’actions utilisateur simple et puissant :

  1. Des actions de déplacement < « » > qui permettent de se déplacer à la dépense suivante / précédente ou suivante non annotée / précédente non annotée.
  2. Une action quit ou q permettant de terminer l’annotation.
  3. Tout le reste permettant de placer une annotation sur la dépense courante (ou la remplacer si elle existe déjà) et se déplacer à la dépense suivante. Avec, bien sûr, l’exception de l’action vide qui ne change pas l’annotation, ce qui donne le plaisir de simplement maintenir la touche ENTRÉE pour naviguer chronologiquement dans les dépenses.

Affichage d’un joli calendrier

Si la majorité du code écrit est triviale, il y a une partie particulièrement pénible reliée à l’affichage du calendrier. Une solution pour afficher un tel calendrier aurait été de coder l’affichage, mais il existe dans la bibliothèque standard de python un module calendar prévu à cet effet. Là où le bât blesse, c’est que les calendriers ainsi définis ne peuvent pas facilement afficher plusieurs dates comme étant “sélectionnées”. Rien qu’un peu de surcharge d’opérations ne puisse régler cependant.

Avant de se lancer dans le code, il faut savoir que pour mettre de la couleur dans un terminal, on peut utiliser les codes d’échappement ASCII, par exemple écrire dans le terminal \033[7m a \033[0m b écrira la lettre a avec un fond différent de la lettre b.

On peut donc créer une classe qui hérite de calendar.TextCalendar (fournie par le module calendar), et surcharger la méthode formatday qui s’occupe d’écrire le numéro du jour sous forme d’une chaine de caractères.

class DayInMonthHighlightingCalendar(calendar.TextCalendar):
    def __init__(self, days_to_highlight : List[datetime.date]):
        super().__init__()
        self._days_to_highlight = days_to_highlight

    # surcharge de la méthode qui affiche les jours
    def formatday(self, day: int, weekday: int, width: int) -> str
        s = super().formatday(day, weekday, width)
        if SHOULD_BE_HIGHLIGHTED(day, weekday):
            s = f"\033[7m{s}\033[0m"
        return s
4.

0 voulant dire que le jour ne fait pas partie du mois, par exemple parce qu’on est en train d’afficher la dernière semaine d’un mois, et que la semaine “déborde” sur le mois suivant. C’est un choix comme un autre, mais particulièrement étrange puisqu’on oublie le numéro du jour par la même occasion.

Jusque-là tout semble bien se passer, sauf que, comme on peut s’en rendre compte dans le programme précédent, il reste à écrire la fonction SHOULD_BE_HIGHLIGHTED. Cela ne devrait pas être trop compliqué, vu que la classe possède une liste de jours à mettre en évidence… Mais si on regarde plus attentivement les arguments de la fonction formatday, elle ne possède pas assez d’information pour retrouver quel jour (de quelle année) elle est en train d’afficher. La variable day contient un entier entre 0 et 31,44 tandis que la variable weekday contient un entier entre 1 et 7.

Il s’agit alors d’ajouter à cette fonction de nouveaux arguments comme suit

    def formatday(self, day: int, weekday: int, width: int, year: int, month: int) -> str:
        s = super().formatday(day, weekday, width)
        if day != 0 and datetime.date(year, month, day) in self._days_to_highlight:
            s = f"\033[7m{s}\033[0m"
        return s

Commence alors un jeu de piste pour trouver, dans le code de la classe calendar.TextCalendar les différentes fonctions qui font appel à formatday, afin de leur ajouter ces nouveaux paramètres. On trouve aisément le code de formatweek qui est à peu près la seule partie qui utilise formatday, et le jeu continue : on ajoute les arguments à formatweek afin de pouvoir corriger l’appel de fonction, et on recherchera par la suite tous les appels à formatweek dans le code de la classe calendar.TextCalendar.

    def formatweek(self, theweek : List[Tuple[int,int]], width : int, year: int, month : int):
        return ' '.join(self.formatday(d, wd, width, year, month) for (d,wd) in theweek)

À ce moment précis, j’ai été plutôt chanceux, puisque la fonction qui appelle formatweek possède déjà les bons arguments (et le cercle infernal s’arrête ici). Il suffit de recopier le code de la fonction, et d’ajouter simplement theyear et themonth à l’appel de formatweek pour terminer notre calendrier illuminé.

    def formatmonth(self, theyear, themonth, w=0, l=0):
        w = max(2, w)
        l = max(1, l)
        s = self.formatmonthname(theyear, themonth, 7 * (w + 1) - 1)
        s = s.rstrip()
        s += '\n' * l
        s += self.formatweekheader(w).rstrip()
        s += '\n' * l
        for week in self.monthdays2calendar(theyear, themonth):
            s += self.formatweek(week, w, theyear, themonth).rstrip()
            s += '\n' * l
        return s

Un dernier problème apparaît lorsqu’on veut afficher le calendrier correspondant à une dépense : il arrive souvent que les dates associées à une dépense soient “à cheval” sur deux mois, par exemple parce que la date de l’opération et la date du débit effectif sont séparées de quelques jours. Il convient alors de faire un petit peu attention dans la fonction print_line_calendar, par exemple en faisant comme dans le code qui suit.

def print_line_calendar(s: State):
    dates = s.data[s.cursor].dates

    if len(dates) == 0:
        return 

    min_date = min(dates.values())
    max_date = max(dates.values())
    cal = DayInMonthHighlightingCalendar(days_to_highlight=list(dates.values()))
    cal.prmonth(min_date.year, min_date.month)
    if min_date.year != max_date.year or min_date.month != max_date.month:
        cal.prmonth(max_date.year, max_date.month)

Ceci n’est pas un logiciel

En conclusion, le petit script (assez facile à faire) est à l’utilisation beaucoup plus efficace que la méthode utilisant Libre Office Calc. De plus, cela a été relativement facile à écrire et le code est assez simple pour être robuste aux évolutions d’export des CSVs de ma banque. Un des avantages non négligeables de l’affichage plus compact est qu’il est possible d’avoir une fenêtre ouverte sur l’internet ou sur son calendrier personnel afin de vérifier les endroits / événements correspondants à une dépense.

Je n’attendais pas une victoire aussi écrasante d’un script en terme d’ergonomie (particulièrement d’un script en ligne de commande) et je me demande si les prochains épisodes utilisant une interface graphique ne vont pas voir la qualité du logiciel diminuer.

Cependant, je tiens à préciser que ce script n’est pas un programme. En particulier, il ne possède aucun test unitaire, n’a pas de possibilités d’internationalisation, n’utilise pas de programme de saisie comme readline, ne définit pas un package, n’est pas portable, et encore bien d’autres. La comparaison avec Libre Office Calc n’est donc pas équitable.


  1. C’est-à-dire, Libre Office Calc, si vous n’avez pas suivi, c’est le point de référence qui servira à l’évaluation du programme proposé dans cet article.↩︎

  2. Cela ne veut pas dire qu’il est facile à décoder ! Il existe une liste de préconceptions (fausses) à propos du format CSV. De manière amusante, c’est aussi vrai pour la gestion des dates.↩︎

  3. On peut remarquer que dans les structures de données choisies, on garde souvent “trop” d’informations. C’est inutile jusqu’à ce que cela devienne indispensable. En effet, garder les lignes “non lues” et garder la version du logiciel ayant produit cet état permet de sérialiser (dans un fichier par exemple) l’état du programme sans trop se poser de questions.↩︎

  4. 0 voulant dire que le jour ne fait pas partie du mois, par exemple parce qu’on est en train d’afficher la dernière semaine d’un mois, et que la semaine “déborde” sur le mois suivant. C’est un choix comme un autre, mais particulièrement étrange puisqu’on oublie le numéro du jour par la même occasion.↩︎