Pourquoi last month n’est pas last month ?

Il y a quelques temps, je suis tombé sur un article de bibelo.info où l’auteur découvrait que la commande Unix date, lorsqu’on lui demande la numéro du mois précédent, ne retournait pas toujours la bonne valeur. Exemple avec Halloween :

$ date +%Y%m -d '2022-10-31 last month'
202210

Après avoir écrit qu’il lui restait à découvrir le pourquoi, il reprend une solution trouvée sur StackOverflow qui contourne le problème en soustrayant le nombre de jours déjà écoulés :

lastmonth=$(date +%Y%m -d "$(date +%d) day ago")

Personnellement, je n’aime pas lorsqu’on mélange les unités. Si on veut retirer un mois, on devrait retirer un mois, pas le nombre de jours déjà écoulés. Le code en serait ainsi plus clair, on respecterait les responsabilités des interfaces et on éviterait de réinventer des roues carrées1 2.

Alors forcément, j’ai cherché pourquoi ce comportement bizarre et comment le résoudre plus proprement…

Que dit la documentation ?

Premier réflexe, man date, pour lire le fabuleux manuel et comprendre comment la commande est sensée fonctionner. Cette page est un peu longue alors voici l’extrait qui nous intéresse :

Chaîne des dates Le format de --date=CHAÎNE est […] plus complexe que ce qui peut être facilement documenté ici, mais est complètement décrit dans la documentation info.

man date, traduction par Michel Robitaille et Guilhelm Panaget

Il faut donc creuser un peu plus loin en consultant la documentation info. En ligne de commande (info date) ou via le web (e.g. sur www.gnu.org). Il faut alors naviguer un peu pour trouver la documentation à propos des durées relatives dans l’écriture des dates (cf. section 29.7).

29.7 Relative items in date strings […]

The fuzz in units can cause problems with relative items. For example, 2003-07-31 -1 month might evaluate to 2003-07-01, because 2003-06-31 is an invalid date. To determine the previous month more reliably, you can ask for the month before the 15th of the current month. For example:

$ date -R
Thu, 31 Jul 2003 13:02:39 -0700
$ date --date='-1 month' +'Last month was %B?'
Last month was July?
$ date --date="$(date +%Y-%m-15) -1 month" +'Last month was %B!'
Last month was June!

Le comportement est donc connu et documenté ; si on veut le mois précédent une date en fin de mois, ça peut fournir une réponse erronée car la date du mois précédent n’existe pas. Si on revient à l’exemple par rapport à Halloween, le 31 septembre n’existe pas et le système choisi de nous répondre le jour suivant, soit le 1er octobre.

$ date -d '2022-10-31 last month' -R
Sam, 01 Oct 2022 00:00:00 +000

La solution proposée par la documentation consiste à créer une date au 15 du mois actuel puis à remonter d’un mois3 4 5 6 7.

Que dit le code ?

On sait peut être maintenant que le comportement est normal, documenté, et comment contourner le problème. Mais pourquoi est-ce ainsi ? Pour le savoir, il faut plonger dans le code source…

Vous pouvez trouver le code complet dans le dépôt git du projet GNU (i.e. date.c). La fonction principale est un peu longue alors voici les extraits intéressants :

int
main (int argc, char **argv)
{
    /* ... */
    struct timespec when;
    /* ... */
    while ((optc = getopt_long (argc, argv, short_options, long_options, nullptr))
           != -1)
      {
        char const *new_format = nullptr;

        switch (optc)
          {
          case 'd':
            if (datestr)
              discarded_datestr = true;
            datestr = optarg;
            break;
          /* ... */
        }
    }
    /* ... */
                valid_date = parse_datetime2 (&when, datestr, nullptr,
                                              parse_datetime_flags,
                                              tz, tzstring);
    /* ... */
}

Après avoir analysé les paramètres en ligne de commande, et stocké la chaîne spécifiant la date de référence dans la chaîne datestr, le code appelle parse_datetime2() pour la convertir en timespec, c’est à dire un nombre de secondes et de nanosecondes (dans notre cas, depuis l’Époque Unix).

Cette fonction est définie dans l’en-tête parse_datetime2() de la librairie de portabilité GNU (cf. depôt git de gnulib). Elle ne fait que déléguer à parse_datetime_body() dont voici les extraits intéressants :

static bool
parse_datetime_body (struct timespec *result, char const *p,
                     struct timespec const *now, unsigned int flags,
                     timezone_t tzdefault, char const *tzstring)
{
    /* ... */
    /* Add relative date.  */
    if (pc.rel.year | pc.rel.month | pc.rel.day)
      {
        if (debugging (&pc))
          {
            if ((pc.rel.year != 0 || pc.rel.month != 0) && tm.tm_mday != 15)
              dbg_fputs (_("warning: when adding relative months/years, "
                           "it is recommended to specify the 15th of the "
                           "months\n"));

            /* ... */
          }
 
        int year, month, day;
        if (ckd_add (&year, tm.tm_year, pc.rel.year)
            || ckd_add (&month, tm.tm_mon, pc.rel.month)
            || ckd_add (&day, tm.tm_mday, pc.rel.day))
          {
            if (debugging (&pc))
              dbg_printf (_("error: %s:%d\n"), __FILE__, __LINE__);
            goto fail;
          }
        tm.tm_year = year;
        tm.tm_mon = month;
        tm.tm_mday = day;
        tm.tm_hour = tm0.tm_hour;
        tm.tm_min = tm0.tm_min;
        tm.tm_sec = tm0.tm_sec;
        tm.tm_isdst = tm0.tm_isdst;
        tm.tm_wday = -1;
        Start = mktime_z (tz, &tm);
    /* ... */
}

Après avoir informé l’utilisateur qu’il devrait peut être utiliser le 15 du mois lorsqu’il veut jouer avec des décalages, le code additionne les années aux années, les mois aux mois et les jours aux jours. Puis la fonction crée une structure de donnée avec les paramètres et tente de créer un timespec valide avec un appel à mktime_z() de gnulib (cf. time_rc.c).

Si nous avions demandé le mois précédent Halloween (31 octobre 2022), le code construit une structure contenant une date factice du 31 septembre 2022 et en demande la conversion.

Elle appelle alors mktime() de la glibc (cf. mktime.c) qui appelle __mktime64() qui appelle __mktime_internal(). Encore une fois les codes sont longs mais voici la portion qui répond à notre question initiale :

const unsigned short int __mon_yday[2][13] =
  {
    /* Normal years.  */
    { 0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365 },
    /* Leap years.  */
    { 0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366 }
  };

/* ... */

__time64_t
__mktime_internal (struct tm *tp,
                   struct tm *(*convert) (const __time64_t *, struct tm *),
                   mktime_offset_t *offset)
{
    /* ... */
    /* Calculate day of year from year, month, and day of month.
       The result need not be in range.  */
    int mon_yday = ((__mon_yday[leapyear (year)]
                     [mon_remainder + 12 * negative_mon_remainder])
                    - 1);
    /* ... */
}

Lors du calcul du timespec, le code converti la date (ou plutôt le jour et le mois) en numéro du jour dans l’année. Pour la date d’exemple, le 31 septembre 2022 correspond au 31 + 243 = 274ème jour dans l’année. mon_yday vaut ainsi 273 (le 1er jour étant le n°0) qui servira pour le calcul du timespec.

Lorsque cette valeur remonte vers le programme date, et que ce programme tente de la formater pour être lisible par un humain, un calcul inverse est fait, ce numéro de jour dans l’année va être converti en jours et mois. Et le 274ième jour est le 1er octobre.

On peut faire la même observation pour la date un mois avant le 31 mars… On construit temporairement le 31 février, soit le 62eme jour de l’année qui tombe, en temps normal, le 3 mars.

$ date -d '2022-03-31 -1 month' -R
Jeu, 03 Mar 2022 00:00:00 +000

Et après ?

Utiliser date pour connaître le mois précédent, c’est un peu comme utiliser Pronote pour donner des devoirs. Ça marche (quoi que pas toujours correctement), mais il y a des solutions plus simple (et plus bénéfique pour la société et la planète en général).

Le problème, c’est que date est conçu pour gérer des dates de manière générale. C’est à dire à la seconde près tout en prenant en compte les fuseaux horaires et les changements d’heure d’été. C’est bien trop puissant pour l’opération dont on a besoin et c’est justement cette puissance (gérer les jours) qui pose problème ici ; nous n’avions pas besoin de plus que des mois et années.

On pourrait très bien arriver au même résultat nous-même. On converti la date en nombre de mois (une année vaut 12 mois), on en enlève un, puis on reconverti en nombre d’années (quotient par 12) et mois (reste de la division par 12). Voici un petit script bash qui le fait automatiquement :

#!/bin/bash

months=$(( $(date "+%Y*12+%m-2") ))
printf "%04d%02d\n"    \
    $(( $value / 12 )) \
    $(( $value % 12 + 1))

Et si c’est la vitesse qui compte pour vous, on peut même écrire un code en C qui produit le même résultat avec plus de simplicité.

#include <stdio.h>
#include <time.h>

int main() {

    time_t      timestamp = time(NULL) ;
    struct tm * now       = localtime(& timestamp) ;
    int         months    = now->tm_year * 12
                          + now->tm_mon 
                          - 1 ;
   
    printf("%04d%02d\n",
           months / 12 + 1900,
           months % 12 + 1) ;
    return 0 ;
}

Pour comparer, le tableau suivant montre le temps pour 5000 exécutions de chacune des variantes. Celle de Bibleo.info (et de Stackoverflow) qui enlève le nombre de jours écoulés, celle de la documentation officielle qui crée une date au 15 du mois et les deux dernières des arsouyes.

Méthode Ryzen 9 4900H 3.29 GHz Xeon X5670 2.93 GHz
bibelo.info 11.842s 24.484s
GNU info 11.652s 24.802s
Les arsouyes - bash 8.306s 18.567s
Les arsouyes - C 2.438s 7.445s

Comme tout hacker qui se respectent, on apprécie toujours d’exploiter un outil pour l’utiliser autrement que prévu et produire des choses chouettes. Mais d’un autre côté, on recherche aussi le Seiryoku Zenyo.

Qu’en disent les IA ?

Si on écoute la rumeur, il ne serait plus nécessaire de réfléchir car les IAs sont capables de le faire pour nous. Plutôt que chercher le pourquoi du comment, il suffirait de le leur demander pour avoir une réponse rapide et efficace…

Chiche : « Pourquoi lorsque je demande à la commande UNIX date quel est le mois précédant le 31 octobre, j’obtiens octobre et pas septembre ? »

Perplexity. Nous a expliqué qu’un mois avant le 31 octobre, ça tombe le 31 septembre mais que Septembre n’ayant que 30 jours, il se rabat sur le dernier jour du mois courant, soit le 31 octobre.

Copilot. Nous a expliqué que si [nous sommes] au 31 octobre, reculer d’un mois [nous] ramène au 30 septembre, ce qui se traduit par le mois d’octobre. On n’en saura pas plus.

ChatGPT. Nous a dit que si ça répond octobre, c’est parce qu’on est le 31 octobre. Sinon c’est peut être à cause de week-end ou de jours fériés en début septembre. Nous obtiendrions un résultat plus précis avec 1 month ago (car last month serait flou).

Tant de ressources pour en arriver là… nous voilà bien avancés.