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 :
- 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.)
- 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):
str]] # le tableau représenté par le CSV
content: List[List[int # la ligne à partir de laquelle il faut lire les données
start: # le chemin du fichier CSV originel path: Path
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).
À 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):
str] # la ligne originale
content: List[str, datetime.date] # les dates présentes
dates: Dict[str] # l’annotation proposée
tag: Optional[str, str] # des colonnes informatives
infos: Dict[float # le débit (négatif)
debit: float # le crédit (positif) credit:
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):
str, int] # quelles colonnes ont des dates
dates: Dict[int # quelle colonne contient le tag
tag: str, int] # quelles colonnes contiennent des informations
infos: Dict[int # quelle est la colonne de débit
debit: int # quelle est la colonne de crédit credit:
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.
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):
str # la version actuelle du programme
version: int # la position de l’utilisateur dans le CSV
cursor: # le mapping défni par l’utilisateur
mapping: CSVMapping # le fichier CSV "interprété"
data: List[CSVLine] str]] # le fichier CSV "originel" unparsed: List[List[
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)= input("Action: ")
i_action if i_action == "q" or i_action == "quit":
break
= actions.get(i_action, update_tag(i_action))
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.
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. ####_#_###[#]####
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
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
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 :
- 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. - Une action
quit
ouq
permettant de terminer l’annotation. - 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
= super().formatday(day, weekday, width)
s if SHOULD_BE_HIGHLIGHTED(day, weekday):
= f"\033[7m{s}\033[0m"
s return s
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:
= super().formatday(day, weekday, width)
s if day != 0 and datetime.date(year, month, day) in self._days_to_highlight:
= f"\033[7m{s}\033[0m"
s 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):
= max(2, w)
w = max(1, l)
l = self.formatmonthname(theyear, themonth, 7 * (w + 1) - 1)
s = s.rstrip()
s += '\n' * l
s += self.formatweekheader(w).rstrip()
s += '\n' * l
s for week in self.monthdays2calendar(theyear, themonth):
+= self.formatweek(week, w, theyear, themonth).rstrip()
s += '\n' * l
s 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):
= s.data[s.cursor].dates
dates
if len(dates) == 0:
return
= min(dates.values())
min_date = max(dates.values())
max_date = DayInMonthHighlightingCalendar(days_to_highlight=list(dates.values()))
cal
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.
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.↩︎
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.↩︎
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.↩︎
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.↩︎