tsv2anything

Retour accueil


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

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é.

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)
-----------

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

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.

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.

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

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.

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

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.