Dompter son namespace avec un zest de suggests

Author : Swann Floc’hlay
Categories : développement, package
Tags :
Date :

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

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 charge walk() depuis le package {purrr}

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 avec devtools::check()
    • Ô joie tout passe ! 🥳

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} !
    • Le code de ggplot2::ggsave() est open-source
    • En y regardant de plus près, on se rend compte que {ggplot2} appelle le package {svglite} pour exporter les figures en format svg

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 ❓

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é que ggplot2::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 fichier DESCRIPTION.
    • 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

💡 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 le check() 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 au check()

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

  • 🧰 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 un warning() dans l’execution conditionnée par le requireNamespace()
  • 🔹 é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() ! 😃

Une autre manière de détecter rapidemement ce type d’erreur lors du développement d’une fonction est de lui associer un test unitaire.

Un test de la fonction save_plot() nous aurait permis de détecter dès le devtools::check() local qu’une dépendance était manquante, sans avoir à passer par le CI.

On obtiendrait directement une erreur similaire à celle observée dans le CI :

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 format pkg::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écifier dependencies = TRUE dans votre yaml.

  1. Alors comme ça on ne connait pas {fusen} ? Aller voir par ici 🙂↩︎

  2. une grammaire qui crée la documentation en un tour de main↩︎

  3. c.f. la documentation roxygen2 de la fonction↩︎

  4. depuis la version 0.4.4 d’{attachment}↩︎


À propos de l'auteur

Swann Floc’hlay

Swann Floc’hlay

Développeuse, formatrice & brasseuse d'R


Comments


Also read