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
(int argc, char **argv)
main {
/* ... */
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)
= true;
discarded_datestr = optarg;
datestr break;
/* ... */
}
}
/* ... */
= parse_datetime2 (&when, datestr, nullptr,
valid_date ,
parse_datetime_flags, tzstring);
tz/* ... */
}
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
(struct timespec *result, char const *p,
parse_datetime_body struct timespec const *now, unsigned int flags,
, char const *tzstring)
timezone_t tzdefault{
/* ... */
/* 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)
(_("warning: when adding relative months/years, "
dbg_fputs "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))
(_("error: %s:%d\n"), __FILE__, __LINE__);
dbg_printf goto fail;
}
.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;
tm= mktime_z (tz, &tm);
Start /* ... */
}
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(struct tm *tp,
__mktime_internal struct tm *(*convert) (const __time64_t *, struct tm *),
*offset)
mktime_offset_t {
/* ... */
/* 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 ;
("%04d%02d\n",
printf/ 12 + 1900,
months % 12 + 1) ;
months 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.