On la connait tous, cette petite vague de fébrilité qui nous traverse quand on regarde tourner le check
de son tout nouveau commit en se disant “Où ça pourrait bien bloquer ?”.
Et soudain, les trois petits ticks salvateurs 0 errors ✔ | 0 warnings ✔ | 0 notes ✔
Allelhuia ! 🎉
On git commit
le tout, on git push
fièrement sa branche et on ouvre sa Pull Request (PR) l’esprit léger et guilleret.
Dans l’idéal, ça marche à tous les coups.
Seulement parfois, patatra ! L’Integration Continue (CI) plante pour un manque de dépendance cachée, alors que tout nous semblait bien déclaré. 🫠
Envie de mettre tout ça au clair ? Embarquez avec moi à la pêche aux Imports et aux Suggests dans nos dépendances !
📦 1. Un package et son CI
Table des matières
Nous voici sur un package d’exemple qui nous permet de générer des graphiques avec {ggplot2}
.
On peut voir sur son README.md
le badge R-CMD-check qui nous indique que ce package est testé grâce à l’Intégration Continue (CI en anglais) des GitHub Actions.
Le CI permet entre autre de lancer automatiquement un check()
de notre package, non plus depuis une installation locale, mais depuis un environment docker minimal. Les étapes à lancer son définies dans le fichier de config R-CMD-check.yaml
.
Bonne nouvelle, le badge est au vert, le CI tourne sans erreurs ! ✅
💡 2. Une nouvelle fonction
Il nous faut une nouvelle fonction pour sauvegarder un graphique sous plusieurs format à la fois.
Je rajoute cette nouvelle fonction dans un fichier save_plot.R
grâce à {fusen}
1, le tout saupoudré d’un peu de documentation façon {roxygen2}
2 et d’un exemple d’utilisation.
Décortiquons un petit peu le code ensemble.
a. la documentation
#' save_plot
#'
#' @param plot ggplot A ggplot object to be saved
#' @param ext character A vector of output format, can be multiple of "png", "svg", "jpeg", "pdf"
#' @param path character A path where to save the output
#' @param filename character The filename for the output, extension will be added
#'
#' @importFrom purrr walk
#' @importFrom ggplot2 ggsave
#' @importFrom glue glue
#'
#' @return None Create output files
#'
#' @export
- Le tag
@param
décrit les quatre paramètres de la fonction - Le tag
@importFrom
spécifie les imports nécessaires pour utiliser la fonction- e.g.
@importFrom purrr walk
chargewalk()
depuis le package{purrr}
- e.g.
b. le corps de la fonction
save_plot <- function(
plot,
ext = c("png", "jpeg", "pdf"),
path = "graph_output",
filename = "output") {
ext <- match.arg(ext, several.ok = TRUE)
# save all format
ext %>% walk(
\(x) ggsave(
filename = file.path(path, glue("{filename}.{x}")),
plot = plot,
device = x
)
)
}
- Cette fonction va utiliser les packages
{purrr}
et{ggplot2}
pour exporter le graphique en plusieurs formats. - La liste des formats d’export est spécifiée par le paramètre
ext
et utilise par défaut les formats png, jpeg et pdf - Le format est ajouté en suffix du nom de fichier par
glue()
c. l’exemple d’utilisation
# create temp dir
tmp_path <- tempfile(pattern = "saveplot")
dir.create(tmp_path)
ext <- c("svg", "pdf")
data <- fetch_dataset(type = "dino")
p <- plot_dataset(
data,
type = "ggplot",
candy = TRUE,
title = "the candynosaurus rex"
)
save_plot(
plot = p,
filename = "dino",
ext = ext,
path = tmp_path
)
# clean
unlink(tmp_path, recursive = TRUE)
- On crée un graphique à l’aide de la fonction
plot_dataset()
déjà implémentée dans le package - On exporte ce graphique en format svg et pdf dans un dossier temporaire que l’on supprime à la fin de l’exemple
🔍 3. Un passage au check
On est bon pour la fonction, maintenant il s’agirait de s’assurer que tout tourne correctement.
Parce que nous ne sommes pas des sauvages, on le fait en deux étapes.
a. le check
local
- Je vérifie d’abord que mon code tourne sur ma machine
- Je lance un
check
local avecdevtools::check()
- Ô joie tout passe ! 🥳
- Je lance un
b. le check
CI
- Je vérifie ensuite que mon code tourne sur un environnement minial grâce au CI
- On envoie le tout sur la remote, et on crée une Pull Request (PR)
- La PR enclenche la batterie de
check
sur cette nouvelle branche - Et là, patatra 😦
🥬 4. Le CI dans les choux
Comment ça des erreurs !? On est pourtant certain d’avoir vérifié que ça marchait ! Notre confiance dans le check
en prend un coup.
Avant de perdre espoir, regardons ça de plus près.
a. le R-CMD-check
- Il semble que les check du CI soient tombées sur un os
- En allant regarder le Details des logs, on tombe sur l’erreur ci-dessous :
Une première piste donc, il semblerait que l’erreur provienne de l’exemple de notre nouvelle fonction save_plot()
.
b. la backtrace
- En descendant un peu plus bas dans les logs, on trouve la backtrace de l’erreur
- La backtrace déroule la séquence des fonctions exécutées avant l’erreur
- On peut ainsi retrouver l’origine du problème dans les sous-fonctions d’un appel
La piste devient un peu plus coton, la backtrace nous indique que l’erreur vient de l’extérieur de notre package, dans la fonction ggsave()
de {ggplot2}
.
c. la fonction ggsave()
- Qu’à cela ne tienne, allons creuser chez
{ggplot2}
!
Bingo ! L’erreur arrive lorsque notre fonction tente de sauver un graphique en format svg mais ne trouve pas le package {svglite}
!
🧶 5. Rembobiner le fil des dépendances
- Notre enquête nous amène alors sur deux questions 🤔 :
- Pourquoi l’import de
{ggplot2}
n’a-t-il pas également chargé{svglite}
comme dépendance ❓ - Pourquoi cette erreur est apparue dans le CI mais pas au
check
local ❓
- Pourquoi l’import de
a. les imports de {ggplot2}
Commençons par cette histoire d’imports.
- Pour utiliser les fonctions de
{ggplot2}
dans notre package, nous avons spécifié queggplot2::ggsave()
fait partie des imports du package3.- Autrement dit, ces imports correspondent aux dépendances indispensables au bon fonctionnement du package
- La liste des dépendances se trouve dans le fichier
DESCRIPTION
- On peut aussi vérifier la liste des dépendances de
{ggplot2}
dans son propre fichierDESCRIPTION
.- Et là que vois-je !
- 👉
{svglite}
fait partie des suggests et non des imports de{ggplot2}
- les suggests listent les dépendances peu utilisées ou à destination des developpeurs (e.g.
{testthat}
) - leur installation comme dépendance n’est pas imposée
- dans le cas de notre CI, la liste des imports de
{ggplot2}
a été installée, mais pas celle des suggests
- dans le cas de notre CI, la liste des imports de
- les suggests listent les dépendances peu utilisées ou à destination des developpeurs (e.g.
💡 Lorsque la pipeline a tenté de sauvegarder le graphique sous svg, elle est remontée jusqu’à la fonction ggplot2::ggsave()
mais n’a pas trouvé le package {svglite}
.
b. et mon check local ?
Comment ce fait-il que ce problème ne survienne pas lors du check local ?
- ✅ Sur notre machine locale, le package
{svglite}
est déjà installé- On va pouvoir le charger sans problème
- On peut lancer localement
save_plot()
et lecheck()
sans problème
- ⚠️ Sur le CI, on utilise des environnements docker minimaux
- Ils n’ont aucun autre package installé que les imports du fichier
DESCRIPTION
{svglite}
ne vas donc pas être installé, et ça va se voir aucheck()
- Ils n’ont aucun autre package installé que les imports du fichier
Soit, {svglite}
n’est pas installé par défault avec notre package, comment remédier à cela ?
⛵️ 6. On embarque {svglite}
Une première solution à notre CI mal en point serait de passer {svglite}
en imports de notre package.
- On ajoute la ligne
#' @importFrom svglite svglite
à la documentation de notre fonction - Cela va ajouter
{svglite}
dans la liste des imports du fichier DESCRIPTION - On vérifie les logs de la mise à jour de la doc :
Est ce que tout rentre dans l’ordre après ça ?
- Le
devtools::check()
local reste au vert. Jusqu’ici tout va bien 🤞 - On test le CI dans une nouvelle Pull Request :
Bingo ! Le CI passe aussi au vert ! 🎉
🤨 7. Il y a comme un truc louche
a. adieu ✔✔✔
On pourrait se satisfaire ce cette version. Sauf qu’elle nous fait l’effet d’un caillou dans la chaussure.
Pourquoi ? Pour ça :
Notre triple vert a disparu ! 😱
En rajoutant la dépendance à {svglite}
, on fait monter à 21 le nombre total de dépendances obligatoires (imports) du package.
A juste titre, le devtools::check()
nous previent que ce n’est pas une situation optimale pour maintenir du code.
Le CRAN conseil de faire passer un maximum de dépendances dans la partie suggests.
b. la pêche aux suggests
Passer un maximum d’imports en suggests soit, mais comment décider du sort de chacune de nos dépendances ?
Selon le CRAN, on peut identifier comme suggests les dépendances qui :
- ne sont pas nécessairement utiles à l’utilisateur, cela inclus :
- les packages utilisés uniquement dans les exemples, les tests et/ou les vignettes
- les packages associées à des fonctions très peu utilisée par l’utilsateur
Prenons le cas de notre dépendance à {svglite}
dans deptrapr::save_plot()
:
- 🔒 on la garde en imports si :
save_plot()
est une fonction phare du package- elle est régulièrement utilisée (on peut alors ajouter svg dans les formats par défaut)
- 🧹 on la passe en suggest si :
save_plot()
est une fonction très peu utilisée- elle est uniquement utilisé dans l’exemple de
save_plot()
Noter qu’avec cette façon de faire, pas besoin d’attendre d’avoir 21 dépendances pour commencer à faire le tri, n’est ce pas ? 🙃
Mettons que save_plot()
soit une fonction mineure de notre package. Comme pour {ggplot2}
, on peut alors passer l’import de {svglite}
dans notre package en suggests.
Faisons cela dans les règles de l’art.
🪄 8. Passe-passe d’imports en suggests
Tentons de migrer notre dépendance à {svglite}
des imports vers les suggests.
a. une bouffée de roxygen
- 🪛 Pour mettre à jour à la main :
- on supprime notre ligne
roxygen2
précédente pour supprimer l’imports de{svglite}
- on lance la commande
usethis::usepackage(package = "svglite", type = "Suggests")
pour cette fois ajouter{svglite}
en suggests
- on supprime notre ligne
- 🧰 Tu préfères utiliser
{attachment}
ou{fusen}
pour mettre à jour tes dépendances ?- dans ce cas, il faut sauvegarder cette modification dans le fichier de configuration4
- on lance la commande
attachment::att_amend_desc(extra.suggests = "svglite", update.config = TRUE)
- avec ça, l’ajout de
{svglite}
sera gardé en mémoire lors du prochain appel àattachment::att_amend_desc()
- ça marche aussi avec
{fusen}
:inflate()
utilisera le fichier de configuration de{attachment}
en arrière-plan !
Une fois {svglite}
passé en suggest, on retrouve nos trois ticks de devtools::check()
local au vert. Hourra ! 🎉
Si on s’arretait ici, notre GitHub CI marcherait sans problème car il installe par défaut :
- les dépendances imports du package, ainsi que leur propre imports
- les dépendances suggests du package (y compris
{svglite}
ici)
Sauf que ce n’est pas suffisant en l’état.
c. éviter le retour de bâton
Pourquoi faire plus ? Pour faire mieux !
Sinon, on refilerait la patate chaude au prochain developpeur ! 😈
Je m’explique : Mettons nous un instant dans la peau de la prochaine personne qui souhaite utiliser notre package.
Si elle tente de sauver son graphique en svg, elle devra, comme nous pour {ggplot2}
, retracer la backtrace jusqu’aux suggests de dépendances.
Ça peut paraitre simple, mais cela peut rapidement s’engluer dans les cas suivants :
- 🔻 il y a 10 packages en suggests
- le CI s’arreterait à chaque dépendance manquante une fois la précédente corrigée 😫
- 🔻 la fonction s’execute après un calcul de 10 minutes
- l’erreur de dépendance nous ferait perdre tous les calculs 😞
- 🔻 notre package utilise beaucoup de fonctions imbriquées
- la backtrace ne nous permettrait pas de remonter jusqu’à l’erreur 😨
🛟 9. Un namespace à l’épreuve
a. le requireNamespace()
Comme nous l’indique si bien la sortie de notre usethis::usepackage()
, pour des suggests au poil, il faut les accompagner d’un requireNamespace()
.
Cette fonction permet de s’assurer ou non que le package est bien installé, et de décider de la manoeuvre à suivre en fonction de la situation.
Cela nous permet deux comportement utiles :
- 🔹 rendre le message d’erreur plus explicite
- on peut préciser quelle est la dépendance manquante et pourquoi elle est nécessaire
- pour cela, on ajoute un
message()
ou unwarning()
dans l’execution conditionnée par lerequireNamespace()
- 🔹 éviter les erreurs
- on peut passer l’exécution si le package n’est pas installé et poursuivre sans erreur
- pour cela, on modifie les paramètres dans l’éxecution conditionnée par le
requireNamespace()
Dans notre cas, on obtient quelque chose comme ça :
save_plot <- function(
plot,
ext = c("png", "jpeg", "pdf"),
path = "graph_output",
filename = "output") {
ext <- match.arg(ext, several.ok = TRUE)
# check svglite is installed
if (!requireNamespace("svglite", quietly = TRUE)) {
warning(
paste(
"Skipping SVG export.",
"To enable svg export with ggsave(),",
"please install svglite : install.packages('svglite')"
)
)
ext <- ext[ext != "svg"]
}
# save all format
ext %>%
walk(\(x) {
ggsave(
filename = file.path(path, glue("{filename}.{x}")),
plot = plot,
device = x
)
})
}
Désormais si {svglite}
n’est pas installé, save_plot()
nous pointe la solution, le tout sans planter !
b. un joli readme
On l’a vu dans nos pérégrinations, {svglite}
ne s’installera pas lorsqu’un utilisateur tentera d’installer notre package {deptrapr}
, car il fait partie des suggests.
Pour éviter à notre utilisateur d’aller farfouiller dans le DESCRIPTION
pour découvrir cette dépendance, on peut lui simplifier la vie avec notre README
.
On y rajoute un paragraphe mentionnant l’utilisation de suggests, et qu’il est possible de les installer en spécifiant le paramètre dependencies = TRUE
lors de l’installation.
☕️ 10. Résultats des courses
Pour une tranquilité d’esprit à tout épreuve pendant que le check
tourne, vous savez maintenant comment assurer vos arrières avec le combo suggests
& requireNamespace()
! 😃
Deux dernières astuces pour la route :
- Les packages qui ne sont pas importés ne font pas partie du
NAMESPACE
, il est donc recommandé de les appeler sous le formatpkg::function()
dans le code, pour éviter les conflits. - Si votre CI utilise
devtools::install()
, par défault les sugggests ne seront pas installés, il faut spécifierdependencies = TRUE
dans votre yaml.