article non relu
Le Besoin
Il m’arrive relativement fréquemment de devoir prendre un TSV et de l’afficher dans un format arbitraire. Par exemple nous pouvons avoir le TSV suivant :
Index First Name Last Name Email Date of birth Job Title
1 Shelby Terrell elijah57@example.net 1945-10-26 Games developer
2 Phillip Summers bethany14@example.com 1910-03-24 Phytotherapist
3 Kristine Travis bthompson@example.com 1992-07-02 Homeopath
4 Yesenia Martinez kaitlinkaiser@example.com 2017-08-03 Market researcher
5 Lori Todd buchananmanuel@example.net 1938-12-01 Veterinary surgeon
6 Erin Day tconner@example.org 2015-10-28 Waste management officer
7 Katherine Buck conniecowan@example.com 1989-01-22 Intelligence analyst
8 Ricardo Hinton wyattbishop@example.com 1924-03-26 Hydrogeologist
9 Dave Farrell nmccann@example.net 2018-10-06 Lawyer
10 Isaiah Downs virginiaterrell@example.org 1964-09-20 Engineer, site
et nous voulons afficher chaque personne sous le format :
[id] | First Name Last Name - Job Title
Date of birth
(Email)
------------------
Implémentation
J’imagine qu’il y a mille façons de faire cela. Je privilégie les implémentations peu verbeuses, POSIX et faisant appel aux abstractions du shell quand cela est possible. Cela écarte de nombreuses solutions possiblement plus élégantes et/ou (beaucoup) plus performantes au profit d’une rapidité d’implémentation et d’une certaine homogénéité d’environnement pour peu que l’on ait l’habitude d’évoluer dans du shell.
Avec printf
printf
est en soit une commande qui permet de mettre des données dans un
certain format. En lui donnant à manger notre tableau et en inscrivant notre
template en argument de printf on peut recréer très rapidement un système de
template.
L’argument correspondant pour printf serait :
%s | %s %s - %s
%s
(%s)
------------
Il y a quelques temps j’aurais naïvement écrit quelque chose sous la forme
< in.tsv tail -n+2 | #retirer le header
tr ' ' '\n' | #mettre un élément par ligne
xargs -d'\n' -n6 printf '%s | %s %s - %s\n%s\n(%s)\n-----------' #donner exactement 6 arguments à printf à chaque fois
ce qui aurait exécuté :
printf %s | %s %s - %s\n%s\n(%s) 1 Shelby Terrell elijah57@example.net 1945-10-26 Games developer
printf %s | %s %s - %s\n%s\n(%s) 2 Phillip Summers bethany14@example.com 1910-03-24 Phytotherapist
printf %s | %s %s - %s\n%s\n(%s) 3 Kristine Travis bthompson@example.com 1992-07-02 Homeopath
printf %s | %s %s - %s\n%s\n(%s) 4 Yesenia Martinez kaitlinkaiser@example.com 2017-08-03 Market researcher
[...]
Sauf que printf
est malin. S’il reçoit plus de paramètres que d’endroits où
les placer dans le template il boucle sur les paramètres suivant. On peut donc
faire un unique appel à printf avec tous les arguments et en plaçant un
judicieux retour à la ligne à la fin du template :
< in.tsv tail -n+2 | #retirer le header
tr ' ' '\n' | #mettre un élément par ligne
xargs -d'\n' printf '%s | %s %s - %s\n%s\n(%s)\n-----------\n' #donner tous les arguments d'un coup
ce qui exécutera cette commande de la mort :
printf '%s | %s %s - %s\n%s\n(%s)\n-----------\n' 1 Shelby Terrell elijah57@example.net 1945-10-26 Games developer 2 Phillip Summers bethany14@example.com 1910-03-24 Phytotherapist 3 Kristine Travis bthompson@example.com 1992-07-02 Homeopath 4 Yesenia Martinez kaitlinkaiser@example.com 2017-08-03 Market researcher 5 Lori Todd buchananmanuel@example.net 1938-12-01 Veterinary surgeon 6 Erin Day tconner@example.org 2015-10-28 Waste management officer 7 Katherine Buck conniecowan@example.com 1989-01-22 Intelligence analyst 8 Ricardo Hinton wyattbishop@example.com 1924-03-26 Hydrogeologist 9 Dave Farrell nmccann@example.net 2018-10-06 Lawyer 10 Isaiah Downs virginiaterrell@example.org 1964-09-20 Engineer, site
qui donnera :
1 | Shelby Terrell - elijah57@example.net
1945-10-26
(Games developer)
-----------
2 | Phillip Summers - bethany14@example.com
1910-03-24
(Phytotherapist)
-----------
3 | Kristine Travis - bthompson@example.com
1992-07-02
(Homeopath)
-----------
[...]
Ah mais l’adresse et le métier on été interchangés ! Normal, l’adresse vient avant le métier dans le tableau et printf ne nous donne pas moyen de “nommer” spécifiquement une colonne dans template. Si dans le template la seconde colonne doit être imprimée avant la première il faut qu’elles apparaissent dans cet ordre là dans la source. Autrement dit il faut réarranger la source des données dans l’ordre d’apparition voulue dans le template. Pour notre cas cela pourrait être :
$ < in.tsv awk -F'\t' -vOFS='\t' '{print $1,$2,$3,$6,$5,$4}' |
head -n2
Index First Name Last Name Job Title Date of birth Email
1 Shelby Terrell Games developer 1945-10-26 elijah57@example.net
Donc si l’on met tout bout à bout :
$ < in.tsv awk -F'\t' -vOFS='\t' '{print $1,$2,$3,$6,$5,$4}' |
tail -n+2 | #retirer le header
tr ' ' '\n' | #mettre un élément par ligne
xargs -d'\n' printf '%s | %s %s - %s\n%s\n(%s)\n-----------\n' #donner tous les arguments d'un coup
1 | Shelby Terrell - Games developer
1945-10-26
(elijah57@example.net)
-----------
2 | Phillip Summers - Phytotherapist
1910-03-24
(bethany14@example.com)
-----------
3 | Kristine Travis - Homeopath
1992-07-02
(bthompson@example.com)
-----------
4 | Yesenia Martinez - Market researcher
2017-08-03
(kaitlinkaiser@example.com)
-----------
[...]
On peut déplier le format printf pour plus de clarté :
xargs -d'\n' printf '
%s | %s %s - %s
%s
(%s)
-----------'
Avantages
- Relativement rapide
Prenons notre fichier de test de 10 lignes et dupliquons le de façon à avoir un fichier de 1 100 000 lignes. L’impression des environs 4 millions de ligne prend autour de 6/7 secondes sur ma machine, à vous de décider si c’est raisonnable ou pas. On doit évidemment pouvoir faire beauuuuucoup plus rapide avec un programme compilé et un peu optimisé.
- Permet de profiter des fonctionnalités de formatage de printf
printf
inclu de nombreuses possibilités de formatage des données. Par
exemple, si l’on ne veut pas que le métier dépasse 10 caractères de long :
%s | %s %s - %.5s
%s
(%s)
-----------
- Peu de ligne de code, possibilité de le réécrire de zéro très rapidement en l’adaptant au besoin
Une remarque ici est que passé une certaine quantité d’arguments il faut
préciser à xargs un nombre max par appel à printf. Si on ne le fait pas il se
pourrait que l’on rencontre la limite du nombre d’argument de votre système et
que la totalité du tableau ne soit pas imprimées. Cet excellent
article évoque le sujet en
détail. En l’occurence xargs --show-limits
permet de savoir ce que l’on
peut mettre derrière -n
mais pour être safe côté portabilité on peut utiliser
le plus grand multiple du nombre d’éléments à afficher dans le template
inférieur à 4096.
Inconvénients
- Pas de séparation template/code
En l’état le template vit dans le code. Il faut donc dupliquer le script en autant de temaplte que l’on a. On devrait pouvoir faire un peu de méta-programmation pour contourner cette contrainte mais je ne l’ai pas fait.
- Nécessite un prétraitement
Tout se passe parfaitement bien si toutes les données sont dans l’ordre mais il est obligatoire d’avoir un léger prétraitement pour réordonner le tableau dans l’ordre du template.
- Le template ne comporte pas de variables nommées
Il faut jeter un oeil aux données (et possiblement au prétraitement) pour se souvenir de quoi va où. Peut mieux faire en terme de maintenabilité.
Variables shell + here-doc “à la catium”
Si on instancie les variables shell
name="Jean"
dob="1994-06-09"
job="mailman"
On peut ensuite les appeler dans un here-doc
<<delim cat
Mister $name, born on $dob, is a $job
delim
Le here-doc peut vivre dans un fichier séparé, nommé layout
par exemple.
L’astuce est donc d’avoir un peu de code qui pour chaque ligne de notre TSV
instancie les variables et appel le here-doc. Le nom de la variable contenant
les valeurs de la première colonne sera la valeur se trouvant dans l’en-tête du
tableau de la première colonne. Le code suivant en est un exemple, il prendra
dans stdin le tableau et en argument le fichier contenant le here-doc :
tmpd=$(mktemp -d)
tee $tmpd/all | head -n1 |
tr ' ' '\n' > $tmpd/vars
tail -n+2 $tmpd/all |
while read line;do
eval $(echo "$line" | tr ' ' '\n' |
paste -d '=' $tmpd/vars - |
sed -E 's/"/\\\"/g' |
sed -E 's/=/&"/;s/$/"/')
. "$1"
done
rm -rf $tmpd
En reprenant notre exmple de printf le layout ressemblera à :
<<delim cat
$id | $firstname $lastname - $job
$dob
($email)
-----------'
delim
Les variables ne pouvant pas contenir d’espace il faudra renommer les entêtes :
id firstname lastname email dob job
1 Terrell elijah57@example.net 1945-10-26 Games developer
2 Phillip Summers bethany14@example.com 1910-03-24 Phytotherapist
[...]
Il reste a appeler notre script (nommé printlayout
pour l’occasion) avec le fichier de notre here-doc en argument :
$ < in.tsv ./printlayout ./layout
1 | Terrell - Games developer
1945-10-26
(elijah57@example.net)
-----------
2 | Phillip Summers - Phytotherapist
1910-03-24
(bethany14@example.com)
-----------
3 | Kristine Travis - Homeopath
1992-07-02
(bthompson@example.com)
-----------
4 | Yesenia Martinez - Market researcher
2017-08-03
(kaitlinkaiser@example.com)
-----------
[...]
Avantages
- Le template est explicite avec des noms de variables aussi claires que vous ne pouvez les nommer
Cela implique également que le prétraitement n’est pas nécessaire pour réordonner le tableau. L’ordre des des données et du template peut être décorrélé. Le template est également plus facile à maintenir.
- N’importe quel traitement shell peut être inclus dans le here-doc
Il est possible de mettre du shell plus avancé dans le template. Par exemple, en utilisant l’interpolation des variables shell, mettre une valeur par défaut si elle n’existe pas dans les données ou terminer l’affichage avec un erreur s’il manque une donnée cruciale :
<<delim cat
${id:?missing id, something is wrong} | $firstname $lastname - ${job:-no known job}
${dob:-date of birth unknown}
($email)
-----------
delim
Il est également possible de mettre des conditions pour que le template diffère légèrement basé sur la valeur d’une variable par exemple :
<<delim cat
$([ "$firstname" = "Alice" ] \
&& $id | $firstname $lastname - $job \
|| $id | $firstname (wow what a pretty name) $lastename - $job
)
${dob:-date of birth unknown}
($email)
-----------
delim
Qui donnera sur les lignes avec Alice comme prénom :
1 | Alice (wow what a pretty name) Terrell - Games developer
1945-10-26
(elijah57@example.net)
-----------
2 | Phillip Summers - Phytotherapist
1910-03-24
(bethany14@example.com)
-----------
Si cela est vraiment un avantage pourrait être sujet à débat. Il pourrait être considéré comme de mauvais goût d’ajouter de l’intelligence dans le template directement. A vous de vous faire votre avis.
Inconvénients
- C’est lent
Cette technique prend entre trois et quatre secondes pour formater 1000 lignes du tableau. Elle n’est pas appropriée pour de grands jeux de données. Pour générer des petites listes pour un site web c’est tout à fait correct et déjà utilisé en production sur plusieurs sites.
D’autres
On peut imaginer pleins d’autres implémentations et je vais tenter de les renseigner ici. Une version assez simpliste contenu dans un binaire C avec un format de templating raisonnable pourrait être le meilleur des deux mondes bien que moins simple à recoder/modifier à la volée.
Je suis également persuadé que l’on pourrait faire quelque chose de bien plus malin et efficace entre perl/awk mais je n’ai pas l’énergie d’y réfléchir à l’instant.