Compatibilité entre le framework .NET historique et .NET


Actuellement, 2 implémentations de .NET peuvent être utilisées:

  • l’implémentation historique du Framework .NET toujours supportée mais dont la version s’arrête à la version majeure 4.8 (actuellement 4.8.1).
  • l’implémentation actuelle .NET anciennement appelée .NET Core jusqu’à la version 3.1 (cf. dotnet.microsoft.com/en-us/download/dotnet).

Le framework .NET existant depuis les années 2000, beaucoup d’applications existent toujours en ayant comme cible le framework historique. Même si la grande majorité des fonctionnalités existent entre le framework .NET et .NET, le passage d’un framework à l’autre est loin d’être trivial. Des solutions existent pour limiter la quantité de code ciblant le framework .NET comme par exemple utiliser .NET Standard. En effet, .NET Standard permet ainsi d’avoir du code utilisable à la fois par des applications ciblant le framework .NET et .NET. Toutefois, de la même façon le passage du code du framework .NET à .NET Standard est loin d’être trivial.

Ainsi dans le but de ne pas avoir à trop migrer du code “legacy”, on peut se poser la question de savoir quelles sont les compatibilités entre le framework .NET historique et l’implémentation plus actuelle de .NET.

Le but de cet article est d’étudier en détails les différences entre le framework .NET et .NET et vérifier quels sont les éléments de compatibilité entre les 2:

  • Quelles sont les différences entre une assembly ciblant le framework .NET et une assembly ciblant .NET sous Windows ?
  • Une assembly ciblant framework .NET peut-elle être utilisée directement dans une application .NET ? Et inversement ?
  • Est-on obligé d’utiliser .NET Standard pour avoir des assemblies communes entre le framework .NET et .NET ?
.NET et .NET 🤯

Dans cet article pour éviter les confusions, on emploiera les termes:

  • “Framework .NET historique” pour désigner le framework .NET dont la version s’arrête à 4.8.x.
  • “.NET” pour désigner l’implémentation actuelle de .NET (anciennement appelée .NET Core).

Dans un premier temps, on va indiquer quelles sont les différences les plus importantes entre une application ciblant le framework .NET historique et la même application ciblant .NET. Ensuite, on va montrer quelles sont les possibilités pour utiliser des assemblies communes sans forcément utiliser .NET Standard.

Fonctionnement générale de .NET

Avant tout, le but de cette partie est de décrire le fonctionnement générale de .NET suivant les 2 implémentations.

Historique de .NET

Framework .NET historique

Historiquement le framework .NET historique a été construit au dessus de Windows ce qui le rend très spécifique à cette plateforme. On peut, toutefois, distinguer 2 types de dépendances sur lesquelles repose une application ciblant le framework .NET historique:

  • Les dépendances systèmes: ensemble de DLL Windows permettant à l’application de s’interfacer avec le système d’exploitation et plus généralement la machine, par exemple: advapi32.dll, kernel32.dll, gdi32.dll etc… (on peut trouver une liste plus exhautive sur Microsoft Windows library files).
  • Le framework .NET historique: avant Windows 10, le framework était distribué de façon séparé du système d’exploitation (cf. dotnet.microsoft.com/en-us/download/dotnet-framework). Il fallait donc l’installer séparément. Depuis Windows 10, le framework fait partie du système d’exploitation et est distribué en même temps sans installation séparée. On peut toutefois considérer les DLL et assemblies du framework .NET historique séparément des DLL système. L’assembly la plus connue faisant partie de cette catégorie est mscorlib.dll.

Historiquement et en particulier avant Nuget (apparu avec le framework 4.0), une application ciblant le framework .NET historique était distribuée sans aucune assembly du framework, seul le code et les assemblies fonctionnelles étaient fournis. De façon à rendre une application moins dépendante de la version du framework installée sur une machine, lors de l’implémentation d’une application, certaines dépendances du framework pouvaient être téléchargées via Nuget à partir du framework 4.0. Ainsi, on peut trouver des packages Nuget comme System.Runtime, System.IO, System.IO.FileSystem etc… Les assemblies dans ces packages sont déployées au même moment que l’application ce qui permet de rendre l’application moins dépendante des assemblies .NET du système d’exploitation.

Le framework .NET historique est toujours supporté toutefois il ne bénéficie plus plus d’évolutions fonctionnelles notables. La dernière version est 4.8.1. Il existe des compatibilités de code avec .NET Core.

.NET Core

L’implémentation .NET Core a commencé en 2016 de la version .NET Core 1.0 jusqu’à la version 3.1. Cette implémentation continue actuellement sous l’appellation .NET. Le plus gros avantage de cette implémentation est qu’elle n’est pas dépendante de la plateforme Windows à l’inverse du framework .NET historique. A la compilation, il est possible de cibler d’autres systèmes d’exploitation comme Linux ou Mac OS. De plus, il est possible de déployer une application .NET Core de façon autonome avec toutes les dépendances dans le même répertoire ce qui limite d’éventuel problème de dépendances non satisfaites à l’exécution.

Au fur et à mesure des versions de .NET Core, les fonctionnalités du framework .NET historique ont été portées sur .NET Core:

  • .NET Core 1.0: portage des fonctionnalités principales du framework .NET historique avec la CoreCLR et la machine virtuelle permettant l’exécution des application .NET.
  • .NET Core 2.0: supporte .NET Standard 2.0. Beaucoup de fonctionnalités sont rajoutés au .NET Standard 2.0 et donc de fait sont rajoutées dans .NET Core 2.0 comme ASP.NET Core 2.0, Entity Framework Core 2.0, Razor Pages, SignalR.
  • .NET Core 3.0: le support de technologie desktop comme WPF ou Windows Forms est rajoutée mais réservé à une plateforme Windows. .NET Core 3.0 introduit des fonctionnalités inédites comme les Web assemblies.

La dénomination .NET Core s’est arrêtée avec la version 3.1 toutefois cette implémentation a continué sous l’appélation .NET.

.NET

Il s’agit actuellement de l’implémentation principale de la technologie .NET. L’appellation a commencé avec la version 5.0 qui est la continuation de .NET Core. Une très grande partie des fonctionnalités du framework .NET historique ont été portées dans .NET. Désormais, les évolutions fonctionnelles ne sont implémentées que dans cette implémentation.

Dans Visual Studio 2022, l’interface ne permet plus de créer une application du framework .NET historique sans éditer directement le fichier .csproj.

Fonctionnement général du CLR

Le but de cette partie est d’évoquer quelques caractéristiques du CLR.

Chargement des assemblies à la demande

Par défaut, le CLR fonctionne en ne chargeant que les types dont il a besoin en mode “lazy loading” c’est-à-dire seulement s’il doit appeler une méthode dans ce type.

Par exemple si on considère le code suivant:

  • Une classe QuickSort se trouvant dans une assembly nommée QuickSort.dll:
    namespace Example
    {
      public class QuickSort
      {
        public QuickSort(int numberCount)
        {
          // ...
        }
    
        public IReadOnlyList<int> OriginalNumbers => this.originalNumbers;
        public IReadOnlyList<int> Sort()
        {
          // ...
        }
      }
    }
    

    Le détail du code n’a pas d’importance, il faut juste prêter attention aux appels qui sont effectués.

  • Une classe QuickSortCaller se trouvant dans une assembly nommée Launcher.dll:
    namespace Launcher
    {
      internal class QuickSortCaller
      {
        public QuickSortCaller()
        {
    
        }
    
        public void CallQuickSort() 
        {
          var quickSort = new QuickSort(10);
          Console.WriteLine($"Original numbers: {string.Join("; ", quickSort.OriginalNumbers)}");
          var sortedNumbers = quickSort.Sort();
          Console.WriteLine($"Sorted numbers: {string.Join("; ", sortedNumbers)}");
        }
      }
    }
    
  • Enfin le "Main" dans l’assembly Launcher.dll:
    namespace Example.Launcher
    {
      class TestClass
      {
        static void Main(string[] args)
        {
          PrintAssemblies();
          Console.ReadLine();
    
          var quickSortCaller = new QuickSortCaller();
          quickSortCaller.CallQuickSort();
    
          PrintAssemblies();
          Console.ReadLine();
        }
    
        public static void PrintAssemblies()
        {
          var assemblies = AppDomain.CurrentDomain.GetAssemblies();
          foreach (var assembly in assemblies)
          {
            Console.WriteLine(assembly.GetName());
          }
        }
      }
    }
    

Le but de cette application est de lancer le tri en ordre croissant de 10 nombres par la classe QuickSort à partir de Launcher.exe. L’exécution est interrompue par des Console.ReadLine() de façon à pouvoir vérifier quelles sont les assemblies qui sont chargées en mémoire.

On se propose de monitorer le chargement des assemblies avec Process Monitor (on peut télécharger cet outil sur learn.microsoft.com/en-us/sysinternals/downloads/procmon).
On le configure en cliquant sur le filtre:

Filtrer dans Process Monitor

On applique les filtres:

  • Process Name is Launcher.exe.
  • Operation is "Load Image".

Ainsi:

  • A la 1ere exécution de Console.ReadLine(), on obtient à l’exécution:
    C:\Users\matmanhatan2906\Dev\NetCoreVsFramework\src\QuickSort\Launcher\bin\x64\Release\net6.0\Launcher.exe
    System.Private.CoreLib, Version=6.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e
    Launcher, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
    System.Runtime, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
    System.Console, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
    

    Dans Process Monitor, on obtient la liste:

    C:\Users\matmanhatan2906\Dev\NetCoreVsFramework\src\QuickSort\Launcher\bin\x64\Release\net6.0\Launcher.exe
    C:\Windows\System32\ntdll.dll
    C:\Windows\System32\kernel32.dll
    C:\Windows\System32\KernelBase.dll
    C:\Windows\System32\user32.dll
    C:\Windows\System32\win32u.dll
    C:\Windows\System32\gdi32.dll
    C:\Windows\System32\gdi32full.dll
    C:\Windows\System32\msvcp_win.dll
    C:\Windows\System32\ucrtbase.dll
    C:\Windows\System32\shell32.dll
    C:\Windows\System32\advapi32.dll
    C:\Windows\System32\msvcrt.dll
    C:\Windows\System32\sechost.dll
    C:\Windows\System32\rpcrt4.dll
    C:\Windows\System32\imm32.dll
    C:\Program Files\dotnet\host\fxr\7.0.2\hostfxr.dll
    C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.13\hostpolicy.dll
    C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.13\coreclr.dll
    C:\Windows\System32\ole32.dll
    C:\Windows\System32\combase.dll
    C:\Windows\System32\oleaut32.dll
    C:\Windows\System32\bcryptprimitives.dll
    C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.13\System.Private.CoreLib.dll
    C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.13\clrjit.dll
    C:\Users\matmanhatan2906\Dev\NetCoreVsFramework\src\QuickSort\Launcher\bin\x64\Release\net6.0\Launcher.dll
    C:\Users\matmanhatan2906\Dev\NetCoreVsFramework\src\QuickSort\Launcher\bin\x64\Release\net6.0\Launcher.dll
    C:\Windows\System32\kernel.appcore.dll
    C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.13\System.Console.dll
    C:\Program Files\dotnet\shared\Microsoft.NETCore.App\6.0.13\System.Threading.dll
    
  • A la 2e exécution de Console.ReadLine(), on obtient à l’exécution:
    Original numbers: 417669634; 1956917617; 711754152; 278682087; 1764089466; 1626660450; 907923799; 1181956495; 1884345336; 1655709668
    Sorted numbers: 278682087; 417669634; 711754152; 907923799; 1181956495; 1626660450; 1655709668; 1764089466; 1884345336; 1956917617
    System.Private.CoreLib, Version=6.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e
    Launcher, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
    System.Runtime, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
    System.Console, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
    System.Threading, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
    System.Text.Encoding.Extensions, Version=6.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
    QuickSort, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
    mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
    

Dans Process Monitor, les lignes suivantes sont rajoutées:

C:\Users\matmanhatan2906\Dev\NetCoreVsFramework\src\QuickSort\Launcher\bin\x64\Release\net6.0\QuickSort.dll
C:\Windows\System32\bcrypt.dll

Ainsi on peut voir que le chargement de l’assembly QuickSort.dll ne s’est fait que lorsqu’il y a eu un appel à une fonction se trouvant dans cette assembly.

Démarrage d’une application .NET

Le démarrage d’une application .NET ne suit pas tout à fait le même ordre dans le cas du framework .NET historique et de .NET:

  • Dans le cas du framework .NET historique, l’application est un exécutable (i.e. fichier .exe) qui ne peut être lancé que sur Windows. Le format de ce fichier correspond à un Portable Executable qui sera reconnu par le système d’exploitation. Ce fichier contient une petite partie de code natif qui sert d’amorce pour démarrer le CLR par l’intermédiaire de mscoree.dll. Toutes les dépendances principales du framework dans mscorlib.dll sont ensuite chargées. Le code .NET se trouve sous la forme de code IL que le CLR va compiler en code machine par l’intermédiaire du compilateur JIT. Le premier module compilé et exécuté est celui correspondant à la fonction main. Suivant l’exécution, les autres parties du code seront compilées et les dépendances chargées au besoin.
  • Pour .NET, le fichier exécutable dépend du système d’exploitation qui a été ciblé lors de la compilation (i.e. runtime packs). Cet exécutable est une application hôte (i.e. “app host”) native qui va servir d’amorce pour charger et exécuter des DLL ayant des responsabilités différentes:
    • hostfxr.dll: cette DLL va sélectionner le bon runtime permettant d’exécuter l’application .NET. Ce runtime dépend du runtime ciblé au moment de la compilation, du système d’exploitation et du runtime réellement installé.
    • hostpolicy.dll: regroupe toutes les stratégies pour charger le runtime, appliquer la configuration, résoudre les dépendances de l’application et appeler le runtime pour exécuter l’application.
    • coreclr.dll: c’est le CLR qui va exécuter le code .NET. Le comportement est ensuite similaire au framework .NET historique: le code .NET sous la forme de code IL est compilé au besoin par l’intermédiaire du compilateur JIT. Ce code est ensuite exécuté.

Chargement des dépendances

Le principe général de chargement des dépendances est le même entre le framework .NET historique et .NET. On distingue les assemblies du framework et les dépendances plus spécifiques au code exécuté ne faisant pas partie du framework. Dans le cas du framework .NET historique, beaucoup de classes de base se trouvent dans mscorlib.dll qui est chargée au démarrage de l’application. Pour .NET, les classes de base se trouvent dans System.Private.CoreLib.dll et dans d’autres assemblies (comme System.Runtime, System.Threading, etc…).

En dehors des classes et des assemblies de base du framework, les dépendances sont chargées par le CLR au besoin si le code exécuté le nécessite. Ainsi si l’exécution d’une portion de code fait appel à un type se trouvant dans une dépendance, cette dépendance doit être chargée en mémoire si ce n’est pas déjà le cas. Le code IL obtenu suite à la compilation indique l’assembly à partir de laquelle ce type peut être trouvé; c’est de cette façon que le CLR, à l’exécution, peut savoir où le récupérer. Par exemple dans le cadre de l’exemple précédent, si on regarde le code IL correspondant à l’appel au code se trouvant dans l’assembly QuickSort.dll:

.method public hidebysig instance void  CallQuickSort() cil managed
{
  // Code size     76 (0x4c)
  .maxstack  3
  .locals init (class [QuickSort]Example.QuickSort V_0,
       class [System.Runtime]System.Collections.Generic.IReadOnlyList`1<int32> V_1)
 //...
}

On peut voir que le classe Example.QuickSort se trouve dans l’assembly QuickSort. A partir des metadatas dans le manifest, on peut trouver les caractéristiques de QuickSort:

.assembly extern QuickSort
{
  .ver 1:0:0:0
}

Si l’assembly est signée par nom fort, les metadatas comportent la clé publique correspondant à la signature:

.assembly extern System.Console
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A )
  .ver 6:0:0:0
}

Ainsi une dépendance externe est identifiée avec le nom de l’assembly, la version et éventuellement la clé publique. Ce fonctionnement est le même entre le framework .NET historique et .NET. En revanche, ce qui diffère est la façon dont les dépendances sont cherchées par le CLR. En effet, lorsque le CLR doit chercher une dépendance qui n’a jamais été chargée, une séquence est lancée pour chercher l’emplacement de cette dépendance.

Processus de recherche des assemblies avec le framework .NET historique

En plus de la version indiquée dans les metadatas du manifest du code IL, le CLR va chercher à déterminer la version de l’assembly à charger suivant des binding redirects pouvant se trouver dans:

  • Le fichier de configuration de l’application <nom exécutable>.exe.config.
  • Le fichier de stratégie de l’éditeur de l’assembly
  • Le fichier de configuration de la machine dans:
    • %windir%\Microsoft.NET\Framework\<version>\config\machine.config
    • %windir%\Microsoft.NET\Framework64\<version>\config\machine.config

Quand le CLR a déterminé la version à charger, il vérifie que l’assembly n’est pas déjà chargée. Si une version différente de l’assembly est déjà chargée, une exception de type FileLoadException est lancée. Si l’assembly possède un nom fort, il est possible d’en charger plusieurs versions (i.e. Side-by-side execution). Pour plus de détails sur la signature par nom fort, voir Signature des assemblies par nom fort en 5 min.

Si l’assembly n’a pas déjà été chargée, une recherche est faite dans le GAC (i.e. Global Assembly Cache). Si le GAC ne permet pas d’obtenir l’assembly à charger, une recherche à d’autres emplacements appelée probing est effectuée (i.e. Default probing). Par défaut cette recherche est effectuée:

  • En vérifiant s’il n’existe pas un élément de configuration <codeBase>
  • Dans le répertoire de l’application ou dans un répertoire avec le même nom que l’assembly:
    • <répertoire de l'application>\<nom assembly>.dll
    • <répertoire de l'application>\<nom assembly>\<nom assembly>.dll
    • <répertoire de l'application>\<culture>\<nom assembly>.dll
    • <répertoire de l'application>\<culture>\<nom assembly>\<nom assembly>.dll
  • Dans les répertoires se trouvant dans l’élément de configuration privatePath modifiable avec le paramètre <probing> dans le fichier de configuration ou dans le code avec AppDomainSetup.PrivateBinPath.
Débugguer la recherche de dépendances avec “fuslog”

Il est possible de débugguer cette séquence de recherche de dépendance en utilisant fuslogvw.exe.

Processus de recherche des assemblies avec .NET

Avec .NET, le chargement des dépendances est différent du mécanisme utilisé pour le framework .NET historique. Avec le framework .NET historique, il existait un AppDomain par défaut dans lequel les assemblies étaient chargées. Quand une assembly était chargée dans un AppDomain, il n’était pas possible de la décharger toutefois il était possible de décharger l’AppDomain entier. Ainsi l’AppDomain constitue une frontière qui permet, par exemple, de charger la même assembly dans des versions différentes. Les appels d’un AppDomain à l’autre se faisait par sérialisation/désérialisation d’objets.

AssemblyLoadContext

Avec .NET (à partir de la version 5), l’objet AppDomain existe toujours toutefois il n’existe qu’un seul AppDomain et il n’est pas possible d’en créer un autre:
AppDomain.CreateDomain(...) mène à une erreur. Les contextes de chargement (i.e. loader context) permettent de remplacer les AppDomains en apportant des fonctionnalités supplémentaires:

  • L’objet représentant un contexte de chargement est AssemblyLoadContext
  • Les contextes de chargement sont nommés, et il n’y a pas de contexte courant comme pour les AppDomains. On peut utiliser la fonction AssemblyLoadContext.GetLoadContext(<assembly>) pour renvoyer le contexte utilisé pour une assembly donnée.
  • Les appels d’un contexte à l’autre ne sont pas très couteux.
  • On peut toujours déchargé un contexte de chargement de façon à décharger une assembly:
    var newLoadContext = new AssemblyLoadContext(name: <nom du contexte>, isCollectible: true);
    // isCollectible à true autorise à décharger le contexte par la suite.
    newLoadContext.LoadFromAssemblyPath(<chemin de l'assembly>);
    // ... 
    newLoadContext.Unload();
    
  • En dérivant de la classe AssemblyLoadContext et en surchargeant les fonctions:
    • Assembly Load(AssemblyName assemblyName) ou
    • IntPtr LoadUnmanagedDll (string unmanagedDllName)

    On peut implémenter un comportement particulier pour charger les dépendances d’une assembly (y compris les dépendances natives). On peut s’aider de la classe AssemblyDependencyResolver dans la résolution des dépendances.
    Voir albahari.com/nutshell/E8-CH18.aspx pour davantage de détails sur l’implémentation.

  • Les fonctions Assembly.Load(byte[]), Assembly.LoadFrom(filename) créent toujours un contexte de chargement séparé.
  • L’intérêt principal de AssemblyLoadContext est de proposer une isolation pour permettre de facilement choisir dans quel contexte les dépendances seront chargées et quel contexte sera utilisé pour accéder à la dépendance d’une assembly. On peut indiquer une portée avec using dans laquelle un autre contexte de chargement sera utilisé:
    var addonLoadContext = new AssemblyLoadContext(...);
    using (addonLoadContext.EnterContextualReflection())
    {
      var addonAssembly = Assembly.Load(<nom de l'assembly>);
    }
    

Recherche par défaut des assemblies

Lors de l’exécution de l’application par l’app host”, après avoir chargé l’assembly coreclr.dll et avant de donner le main au CoreCLR, “l’app host” affecte certaines propriétés pour indiquer des éléments de contexte à l’exécution:

  • TRUSTED_PLATFORM_ASSEMBLIES: liste des chemins des assemblies du framework (managées et natives).
  • APP_PATHS et APP_NI_PATHS: chemin de l’application
  • NATIVE_DLL_SEARCH_DIRECTORIES: liste des chemins des répertoires qui seront parcourus pour chercher les DLL natives.
  • PLATFORM_RESOURCE_ROOTS: liste des chemins des répertoires qui seront parcourus pour chercher les assemblies satellites (assemblies de ressources).
  • APP_CONTEXT_BASE_DIRECTORY: répertoire de base de l’application
  • APP_CONTEXT_DEPS_FILES: liste des fichiers contenant les dépendances, les données du contexte de compilation et les dépendances de compilation pour l’application.
  • FX_DEPS_FILE: liste des fichiers contenant les dépendances du framework.

La valeur de ces propriétés peut être obtenue avec:

AppContext.GetData(<nom de la propriété>);

Ces propriétés sont utilisées par le CLR pour trouver les dépendances managées ou natives lors de l’exécution de l’application. Par défaut, le contexte de chargement AssemblyLoadContext.Default parcourt les chemins indiqués dans les propriétés TRUSTED_PLATFORM_ASSEMBLIES et APP_PATHS.

Les assemblies satellites (assemblies de ressources) sont cherchées dans les répertoires indiquées par les propriétés PLATFORM_RESOURCE_ROOTS et APP_PATHS en ajoutant l’extension liée au nom de la culture:

  • <répertoire>\<culture>\<nom assembly>.dll
  • <répertoire>\<culture>\<nom assembly>\<nom assembly>.dll
Comment débugguer la recherche de dépendances ?

On peut voir des logs concernant la recherche des dépendances en affectant les variables d’environnement suivantes avant d’exécuter l’application:

  • COREHOST_TRACE=1: permet d’indiquer qu’on souhaite activer les logs.
  • COREHOST_TRACEFILE=out.txt: permet d’indiquer le fichier de sortie dans lequel on souhaite envoyer les logs.
  • COREHOST_TRACE_VERBOSITY=4: pour indiquer le niveau de logs (4 étant le maximum).

Pour ajouter ces variables lors du débug de l’application dans Visual Studio:

  1. Debug<nom du projet> Debug Properties
  2. Rajouter les variables dans la partie Environment Variables

Dans le cas d’une exécution à la ligne de commandes, on peut créer un .bat contenant:

set COREHOST_TRACE=1
set COREHOST_TRACEFILE=out.txt
set COREHOST_TRACE_VERBOSITY=4

Launcher.exe

Redirection de type

La fonctionnalité de “type forwarding” (i.e. redirection de type) permet de déplacer un type d’une assembly à une autre sans avoir à changer les références des assemblies utilisant ce type. Cette fonctionnalité est régulièrement utilisée par Microsoft lors des changements de framework pour assurer la retrocompatibilité: par exemple lorsque des types ont été déplacés de System.Core.dll vers mscorlib.dll entre les frameworks 3.5 et 4 ou plus récemment lorsque des types de mscorlib.dll sont déplacés dans System.Private.CoreLib entre les frameworks .NET 4.8 et .NET Core.

Cette fonctionnalité utilise l’attribut TypeForwardToAttribute pour indiquer vers quelle assembly le type est transféré.

Pour illustrer, on prend l’exemple d’une application permettant d’appliquer l’algorithme “Quick sort” à une liste d’entiers. Le but n’est pas de rentrer dans les détails de l’algorithme mais d’avoir un exemple d’appel de fonctions. Ainsi:

  • L’algorithme se trouve dans une assembly nommée QuickSort:
    namespace Example;
    
    public class QuickSort
    {
      private readonly int[] originalNumbers;
      public QuickSort(int numberCount)
      {
        this.originalNumbers = new int[numberCount];
        Random rnd = new Random();
    
        for (int i = 0; i < numberCount; i++)
        {
          originalNumbers[i] = rnd.Next();
        }
      }
    
      public IReadOnlyList<int> OriginalNumbers => this.originalNumbers;
      public IReadOnlyList<int> Sort()
      {
      // ...
      }
    }
    
  • L’algorithme est appelé à partir d’une autre assembly nommée Launcher.exe:
    using System;
    using Launcher;
    
    namespace Example.Launcher
    {
      class TestClass
      {
        static void Main(string[] args)
        {
          var quickSort = new QuickSort(10);
          Console.WriteLine($"Original numbers: {string.Join("; ", quickSort.OriginalNumbers)}");
          var sortedNumbers = quickSort.Sort();
          Console.WriteLine($"Sorted numbers: {string.Join("; ", sortedNumbers)}");
        }
      }
    }
    
Code source

Le code source complet de cet exemple se trouve dans le repository GitHub github.com/msoft/NetCoreVsFramework.

Pour résumer Launcher.exe appelle une classe et une fonction dans QuickSort.dll. Ainsi Launcher.exe référence QuickSort.dll. On décide de déplacer le code dans QuickSort.dll dans une autre assembly appelée NewQuickSort.dll sans changer la référence dans Launcher.exe donc:

  • On supprime le code se trouvant dans QuickSort.dll.
  • On référence l’assembly NewQuickSort.dll dans QuickSort.dll et
  • On ajoute l’attribut TypeForwardedTo() dans QuickSort.dll pour transférer le type Example.QuickSort vers NewQuickSort.dll:
    using System.Runtime.CompilerServices;
    
    [assembly: TypeForwardedTo(typeof(Example.QuickSort))]
    
    namespace Example;
    

Le type Example.QuickSort se trouve désormais dans l’assembly NewQuickSort.dll. La référence typeof(Example.QuickSort) dans QuickSort.dll est possible car QuickSort.dll référence NewQuickSort.dll. Enfin l’assembly Launcher.exe n’est pas modifiée, elle référence uniquement QuickSort.dll et le code Example.QuickSort qui est censé s’y trouver.

A l’exécution, Launcher.exe instancie la classe Example.QuickSort à partir de la référence vers l’assembly QuickSort.dll. Le CLR charge le type directement dans l’assembly NewQuickSort.dll à cause du transfert de type de QuickSort.dll vers NewQuickSort.dll.

TypeForwardedFrom

On peut utiliser l’attribut System.Runtime.CompilerServices.TypeForwardedFromAttribute pour indiquer l’assembly à partir de laquelle un transfert de type est effectué. L’indication se fait en utilisant le nom complet de l’assembly comportant l’indication TypeForwardedTo. L’utilisation de l’attribut TypeForwardedFromAttribute est dans le but de documenter le transfert de type, il n’y a pas de traitement effectué par le CLR lorsque cet attribut est utilisé.

Dans notre exemple, on pourrait rajouter l’attribut TypeForwardedFromAttribute dans l’assembly NewQuickSort.dll pour indiquer que le transfert du type Example.QuickSort provient de l’assembly QuickSort.dll:

namespace Example;

[System.Runtime.CompilerServices.TypeForwardedFrom("QuickSort, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null")]
public class QuickSort
{
  // ...
}

Différences de structure et de dépendances entre le framework .NET historique et .NET

Dans un 1er temps, on va indiquer quels sont les éléments constituant la structure d’une assembly .NET. Dans une 2e temps, on va chercher à trouver les différences de structure entre des assemblies ciblant des frameworks différents.

Structure d’une assembly

On peut se poser la question de savoir quelles sont les différences de structure des assemblies entre le framework .NET historique et .NET d’une part et les assemblies .NET ciblant Windows et ciblant Linux ou Mac OS d’autre part.

Le code .NET pouvant être exécuté est stocké dans des fichiers appelés assemblies. Ces assemblies sont composés des éléments suivants:
Les assemblies sont composées des éléments suivants:

  • PE Header: l’assembly est structurée dans un objet PE de plus bas niveau. A ce titre, il possède ce type d’en-tête.
  • Un manifest contenant une liste des références externes de l’assembly.
  • Sections contenant du code natif compilé.
  • Dans le cas d’assembly managée:
    • CLR Header: présent dans le cas d’une assembly managée. Ce sont des informations sur la version cible du framework .NET historique; éventuellement le hash pour la signature par nom fort (cf. Signature par nom fort); l’adresse dans le fichier des ressources et le point d’entrée indiquant la table des métadonnées permettant à l’assembly de s’autodécrire.
    • Liste des objets binaires utilisés dans les métadonnées.
    • Liste des chaines de caractères utilisées dans les métadonnées.
    • Liste des chaines de caractères utilisées dans le code IL (i.e. Intermediate Language).
    • Liste des GUID utilisés dans l’assembly.
    • Tables des métadonnées permettant d’indiquer des informations sur tous les types utilisés dans l’assembly.
    • Le code IL (i.e. Intermediate Language).

PE Header

Cet en-tête se trouve dans des fichiers dont le format est PE pour Portable Executable. Les fichiers contenant un en-tête PE Header sont des PE objects (i.e. objet PE). Ce format est commun pour structurer des fichiers différents comme des exécutables, des bibliothèques ou des drivers système. Il définit une structure connue du système d’exploitation pour qu’il puisse savoir où trouver des informations qui lui permettront de mapper le contenu du fichier organisé en sections à des zones en mémoire. Cette structure est indiquée dans l’en-tête du fichier (i.e. PE Header).

Le PE Header n’est pas spécifique à des assemblies .NET, il est aussi utilisé dans le cas de DLL natives.

Module

Un module est aussi un terme utilisé en managée et en non managée. Il désigne une unité de compilation contenant des métadonnées des types qu’il contient et du code compilé. Le code compilé n’est pas forcément du code machine. Il correspond à un niveau d’échelle plus élevé que les objets PE.

Un module ne peut pas être déployé seul et il ne contient pas de manifest c’est-à-dire qu’il n’indique pas quelles sont ses dépendances. Chaque module contient un seul type de code.

L’intérêt des modules est de pouvoir être utilisés indépendamment dans une unité déployable comme les assemblies.

Assembly

Une assembly est une unité déployable en opposition aux modules qui sont des unités de compilation. L’assembly peut ainsi être déployée seule. D’une façon générale, il s’agit de fichiers avec une extension .exe pour un exécutable ou .dll pour une bibliothèque de classes.

En .NET, les assemblies sont assez souples pour contenir un ou plusieurs modules, éventuellement des fichiers de ressources et des métadonnées. Les modules peuvent être compilés dans des langages différents. Il est aussi possible de merger le contenu de plusieurs assemblies dans une seule assembly.

On considère plusieurs types d’assemblies:

  • assembly managée: objet PE contenant du code managé. En .NET c’est la plus petite unité de déploiement. Dans la pratique, ces fichiers peuvent être des exécutables ou des bibliothèques de classes. Le plus souvent quand on utilise le terme assembly c’est pour désigner les assemblies managées.
  • assembly mixte: assembly .NET contenant à la fois du code managé et du code natif (cf. C++/CLI).
  • assembly native: on retrouve le terme assembly native dans la documentation Microsoft concernant WinSxS. Ce terme est ambigu car il laisse penser qu’il désigne d’assemblies .NET contenant seulement du code natif or, dans le cas de WinSxS, on parle bien de DLL natives classiques.En effet, en .NET, on distingue les assemblies (sous-entendu les assemblies managées) qui contiennent exclusivement du code managé et les assemblies mixtes contenant, à la fois du code managé et du code natif. Ainsi dans le cas où il n’y a que du code natif, le terme assembly native désigne un groupe d’une ou plusieurs DLL natives, de composant COM ou des collections de ressources, de types ou d’interfaces.
  • Side-by-side assembly: assembly native contenant une liste de ressources ou un groupe de DLL avec un manifest. Le loader du système d’exploitation utilise les informations du manifest pour identifier l’assembly et être capable de la charger quand un exécutable a une dépendance vers cette dernière. Elles ont une identité unique et sont utilisées pour éviter de casser des dépendances.
  • Private assembly: assembly native utilisée seulement par une seule application. Elle peut être inclue en tant que ressource d’une autre DLL ou installer dans le même répertoire que l’exécutable qui l’utilise.
  • Shared assembly: side-by-side assembly déployée dans le répertoire du cache des assemblies du système WinSxS. Ces assemblies peuvent être utilisées par un exécutable si la dépendance est indiquée dans son manifest.

Dans le cadre de cet article, par simplification, on ne s’intéressera qu’aux assemblies managées.

Code MSIL

En .NET, le code n’est pas directement compilé en code machine comme cela est le cas pour du code C++ natif. Le code .NET est compilé dans des assemblies contenant des instructions MSIL (pour MicroSoft Intermediate Language). Ces instructions sont exécutables par le CLR (i.e. Common Language Runtime).

Compilation avec Roslyn vs Compilation avec le JIT

A l’exécution et suivant les besoins du CLR, les instructions MSIL sont de nouveau compilées en code machine par le compilateur JIT (i.e. Just In Time). Le code machine généré est ensuite exécuté par la machine. Les instructions MSIL sont compilées à la demande, en fonction des appels qui sont effectués. Si des instructions correspondant à une fonction ne sont pas appelées alors ces instructions ne seront pas compilées par le compilateur JIT. D’autre part, le compilateur JIT effectue des optimisations dans le code généré suivant la façon dont les fonctions sont appelées. Ainsi les performances d’exécution d’un programmation peuvent s’améliorer au fur et à mesure de son exécution.

On peut trouver le terme CIL (pour Common Intermediate Language) pour désigner du code IL. Il correspond aux mêmes jeux d’instructions que le MSIL toutefois ce terme est utilisé dans le cadre du standard CLI (i.e. Common Language Infrastructure).

Comparaison entre des assemblies ciblant différents frameworks

On va considérer du code .NET simple que l’on va stocker dans une bibliothèque de classes (i.e. class library) nommée QuickSort.dll et on va cibler le framework .NET historique 4.8, .NET 6.0 sous Windows et sous Linux.

Le code correspondant à une classe dont la structure est (le code précis n’a pas d’importance mais on peut le consulter sur GitHub src/QuickSort/QuickSort/QuickSort.cs):

namespace Example
{ 
  public class QuickSort
  {
    private readonly int[] originalNumbers;
    public QuickSort(int numberCount)
    {
      [...]
    }

    public IReadOnlyList<int> OriginalNumbers => this.originalNumbers;
    public IReadOnlyList<int> Sort()
    {
       [...]
    }
    private static void Quicksort(int[] numbers, int first, int last)
    {
      [...]
    }
  }
}
Comment générer dans Visual une assembly en ciblant le framework .NET historique 4.8 ?

Il faut éditer le fichier projet en faisant un clique droit sur le projet puis “Edit Project File” et modifier le contenu de cette façon:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Library</OutputType>
    <TargetFramework>net4.8</TargetFramework>   
    <ImplicitUsings>disable</ImplicitUsings>
    <Nullable>disable</Nullable>
  </PropertyGroup>
</Project>
    
  1. Enregistrer le fichier .csproj.
  2. Faire un clique droit sur le projet puis Publish.
  3. Sélectionner les valeurs suivantes:
    • Configuration: Release | Any CPU
    • Target Framework: net4.8
    • Target runtime: Portable
    • Target location: bin\Release\net4.8\publish\win-x64\
  4. Cliquer sur Publish

Comment générer dans Visual une assembly avec pour runtime cible linux ?

Le contenu du fichier .csproj doit permettre de cibler .NET 6.0:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Library</OutputType>
    <TargetFramework>net6.0</TargetFramework>
    <ImplicitUsings>disable</ImplicitUsings>
    <Nullable>disable</Nullable>
  </PropertyGroup>
</Project>
      
  1. Puis faire un clique droit sur le projet puis Publish.
  2. Sélectionner les valeurs suivantes:
    • Configuration: Release | Any CPU
    • Target Framework: net6.0
    • Deployment mode: Portable
    • Target runtime: linux-x64
    • Target location: bin\Release\net6.0\publish\linux-x64\
  3. Cliquer sur Publish

Refaire la même opération avec Target runtime: win-x64 et Target location: bin\Release\net6.0\publish\win-x64\.

Les assemblies générées devraient se trouver dans les répertoires:

  • Runtime ciblant framework 4.8 pour Windows: bin\Release\net4.8\publish\win-x64\
  • Runtime ciblant .NET 6.0 pour Windows: bin\Release\net6.0\publish\win-x64\
  • Runtime ciblant .NET 6.0 pour Linux: bin\Release\net6.0\publish\linux-x64\

Si on compare les assemblies entre elles, on peut voir qu’elles ne sont pas tout à fait identiques:

  • Entre les assemblies .NET 6.0 ciblant Windows ou Linux:
    fc /b bin\Release\net6.0\publish\linux-x64\QuickSort.dll bin\Release\net6.0\publish\win-x64\QuickSort.dll
    Comparing files BIN\RELEASE\NET6.0\PUBLISH\LINUX-X64\QuickSort.dll and BIN\RELEASE\NET6.0\PUBLISH\WIN-X64\QUICKSORT.DLL
    00000088: 4F AF
    00000089: 7B 74
    0000008A: 7D 07
    0000008B: B7 8F
    ...
    
  • Entre les assemblies .NET 6.0 ciblant Windows et l’assembly ciblant le framework .NET historique 4.8, les différences sont plus importantes:
    fc /b bin\Release\net4.8\publish\win-x64\QuickSort.dll bin\Release\net6.0\publish\win-x64\QuickSort.dll
    Comparing files BIN\RELEASE\NET4.8\PUBLISH\WIN-X64\QuickSort.dll and BIN\RELEASE\NET6.0\PUBLISH\WIN-X64\QUICKSORT.DLL
    00000084: 4C 64
    00000085: 01 86
    00000086: 03 02
    00000088: 08 AF
    00000089: 67 74
    0000008A: FE 07
    0000008B: BC 8F
    [...]
    FC: BIN\RELEASE\NET4.8\PUBLISH\WIN-X64\QuickSort.dll longer than BIN\RELEASE\NET6.0\PUBLISH\WIN-X64\QUICKSORT.DLL
    

Dans un 1er temps, on peut comparer les headers de ces fichiers avec dumpbin. Cet utilitaire permet d’afficher le PE Header de DLL. On peut obtenir cet outil en installant les “C++ profiling tools” dans la rubrique “Desktop development with C++” de l’installateur de Visual Studio. Après installation, cet outil se trouve dans un répertoire du type:
C:\Program Files\Microsoft Visual Studio\<Version VS>\Community\VC\Tools\MSVC\<Version compilateur>\bin\Hostx64\x64\dumpbin.exe.

Par exemple:

C:\Program Files\Microsoft Visual Studio\2022\Community\VC\Tools\MSVC\14.34.31933\bin\Hostx64\x64\dumpbin.exe

Ainsi, si on extrait le header pour chaque DLL en exécutant à partir de l’invite de commande “Native tools Command prompt”:

dumpbin /headers <chemin de l'assembly>

En comparant les headers des différentes versions de l’assembly QuickSort.dll, on peut voir que:

  • Il n’y a pas de différences de headers entre les assemblies ciblant .NET 6.0 pour Windows ou pour Linux et
  • La seule différence notable dans le header entre une assembly ciblant .NET 6.0 et une autre assembly ciblant le framework .NET historique est dans la donnée "subsystem version":
    Extrait du header de l’assembly ciblant le framework .NET historique: Extrait du header de l’assembly ciblant .NET 6.0:
    OPTIONAL HEADER VALUES
          20B magic # (PE32+)
          48.00 linker version
          A00 size of code
          400 size of initialized data
            0 size of uninitialized data
            0 entry point
         2000 base of code
        180000000 image base (0000000180000000 to 0000000180005FFF)
         2000 section alignment
          200 file alignment
         4.00 operating system version
         0.00 image version
         6.00 subsystem version
    [...]
    OPTIONAL HEADER VALUES
          20B magic # (PE32+)
          48.00 linker version
          A00 size of code
          400 size of initialized data
            0 size of uninitialized data
            0 entry point
         2000 base of code
        180000000 image base (0000000180000000 to 0000000180005FFF)
         2000 section alignment
          200 file alignment
         4.00 operating system version
         0.00 image version
         4.00 subsystem version
    [...]

Les différences se trouvent donc ailleurs.

On se propose, ensuite, de comparer les metainfos des assemblies en utilisant ildasm (cet outil se trouve dans un chemin de type: C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8.1 Tools). Pour effectuer cette comparaison, on ouvre ildasm pour chaque assembly et on affiche les metainfos en allant dans ViewMetainfoShow (il faut que dans le menu metainfo aucune entrée ne soit cochée).

En comparant les metainfos des différentes versions de QuickSort.dll, on peut indiquer:

  • Il n’y a pas de différences entre les assemblies ciblant .NET 6.0 pour Windows ou pour Linux et
  • Entre des assemblies ciblant le framework .NET historique et NET 6.0, on peut citer quelques différences:
    • La référence vers l’assembly principale spécifique à chaque framework:
      • System.Runtime pour .NET 6.0
      • mscorlib pour le framework .NET historique
    • La présence d’attributs générés par le compilateur dans l’assembly ciblant .NET:
      • System.Runtime.CompilerServices.RefSafetyRulesAttribute: permettant d’indiquer que le module a été compilé en respectant les règles de sécurité sur les ref C# 11.
      • System.Runtime.CompilerServices.CompilerGeneratedAttribute: permettant de distinguer un objet généré par le compilateur d’un objet généré par l’utilisateur.
    • La valeur de l’attribut System.Runtime.Versioning.TargetFrameworkAttribute:
      • .NETCoreApp,Version=v6.0 ou
      • .NETFramework,Version=v4.8

Ces différences peuvent se voir aussi directement en comparant le manifest entre les 2 assemblies:

Extrait du manifest de l’assembly ciblant le framework .NET historique: Extrait du manifest de l’assembly ciblant .NET 6.0:
// Metadata version: v4.0.30319
.assembly extern mscorlib
{
  .publickeytoken = (B7 7A 5C 56 19 34 E0 89 ) 
  .ver 4:0:0:0
}
.assembly QuickSort
{
  // [...]
}
.module QuickSort.dll
// MVID: {F3745038-C191-4DBF-BEBB-3621DFC9BE88}
.imagebase 0x0000000180000000
.file alignment 0x00000200
.stackreserve 0x0000000000400000
.subsystem 0x0003     // WINDOWS_CUI
.corflags 0x00000001  //  ILONLY
// Image base: 0x06950000
// Metadata version: v4.0.30319
.assembly extern System.Runtime
{
  .publickeytoken = (B0 3F 5F 7F 11 D5 0A 3A ) 
  .ver 7:0:0:0
}
.assembly QuickSort
{
  // [...]
}
.module QuickSort.dll
// MVID: {65CC543B-740E-4A65-A06C-529083C52F97}
.custom instance void 
  System.Runtime.CompilerServices.RefSafetyRulesAttribute::.ctor(int32) = 
  ( 01 00 0B 00 00 00 00 00 ) 
.imagebase 0x0000000180000000
.file alignment 0x00000200
.stackreserve 0x0000000000400000
.subsystem 0x0003     // WINDOWS_CUI
.corflags 0x00000001  //  ILONLY
// Image base: 0x08DF0000

Equivalences entre le framework .NET historique et .NET

Comme on a pu le voir précédemment, le fonctionnement général de .NET est similaire entre .NET et le framework .NET historique. Les différences les plus importantes sont dans l’amorce de l’exécutable, les répertoires parcourus pour charger les dépendances et les dépendances elles-mêmes. De ces 3 éléments, les dépendances vont fortement contribuer à rendre plus difficile la compatibilité entre .NET et le framework .NET historique. Il existe des solutions pour limiter les incompatibilités entre les 2 implémentations de façon à porter des implémentations d’un framework à l’autre en gardant en tête que le portage le plus utile est du framework .NET historique vers .NET. L’autre sens n’a pas forcément un intérêt mais on étudiera quand même cette possibilité.

La solution la plus triviale pour rendre du code prévu pour le framework .NET historique compatible avec .NET est d’utiliser .NET Standard. Toutefois dans les cas où on ne peut pas refactorer ou recompiler des assemblies, il existe d’autres solutions.

.NET Standard

.NET Standard est l’approche historique de Microsoft pour trouver une compatibilité entre le framework .NET historique et les nouveaux frameworks .NET Core/.NET. L’approche a été d’inclure des types et objets dans le standard, chaque framework implémente le standard. Ainsi lorsqu’on construit une application ou une assembly, on ne dépend plus d’un framework mais du standard. La construction de .NET Standard permet ensuite de garantir que si une assembly dépend du standard alors elle est compatible avec les frameworks implémentant ce standard (pour davantage de détails sur la construction de .NET Standard, voir Comprendre .NET Standard en 5 min). La 1ère version de .NET Standard est la version 1.0, elle comprend peu d’objets mais beaucoup de frameworks l’implémentent.

D’une façon générale, plus on incrémente les versions de .NET Standard et plus le standard prend en compte des types et des objets (voir le tableau sur dotnet.microsoft.com/en-us/platform/dotnet-standard#versions):

Comparaison entre .NET Standard 1.0 et .NET Standard 2.1

Ainsi:

  • Plus on incrémente les versions de .NET Standard et moins de framework implémente le standard: cela s’explique par le fait que les frameworks les plus obsolètes n’évoluent plus.
  • La dernière version du standard c’est-à-dire la 2.1 n’est pas prise en charge par le framework .NET historique. Le framework .NET 4.8.x implémente au maximum .NET Standard 2.0.

Lorsqu’on développe une application ou des assemblies, il est tentant de les faire dépendre de .NET Standard plutôt qu’un framework particulier. Il faut, toutefois, avoir en tête que l’approche .NET Standard est abandonnée et ce standard ne sera plus incrémenté(*). Ainsi la version 8.0 de .NET qui est la dernière version de ce framework ne prend en charge aucune version de .NET Standard. La dernière version de .NET implémentant .NET Standard est la version 7.0. .NET Standard convient bien pour les anciennes applications, en particulier celles utilisant le framework .NET historique car cela permet de procéder par étape si on souhaite porter ces applications sur .NET. Toutefois dans le cadre de nouvelles applications, il vaut mieux utiliser directement .NET.

En effet, actuellement la très grande majorité des fonctionnalités du framework .NET historique existe en .NET y compris les fonctionnalités spécifiques aux plateformes Windows (WPF, WinForms, C++/CLI etc…). D’autre part, lorsqu’une fonctionnalité dans le framework .NET historique n’existe pas en .NET, il existe des équivalents. Il n’y a donc plus de raisons de chercher à garder une compatibilité avec la framework .NET historique.

Exemple d’utilisation de .NET Standard

A titre d’exemple, on se propose d’utiliser .NET Standard comme framework cible pour des assemblies de type bibliothèque de classes (i.e. class library). Dans notre exemple, on considère 2 assemblies:

  • QuickSort.dll: bibliothèque de classes comportant la classe QuickSort (l’implémentation de cette classe n’a pas d’importance):
    namespace Example
    {
      public class QuickSort
      {
      // ...
      }
    }
    
  • Launcher.exe: exécutable consommant l’assembly QuickSort.dll:
    namespace Example.Launcher
    {
      class TestClass
      {
        static void Main(string[] args)
        {
          var quickSort = new QuickSort(10);
          Console.WriteLine($"Original numbers: {string.Join("; ", quickSort.OriginalNumbers)}");
          var sortedNumbers = quickSort.Sort();
          Console.WriteLine($"Sorted numbers: {string.Join("; ", sortedNumbers)}");
        }
      }
    }
    

Ces 2 assemblies ciblent .NET 6.0. Si on regarde la propriété TargetFramework dans les .csproj:

  • QuickSort.dll:
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Library</OutputType>
        <TargetFramework>net6.0</TargetFramework> 
      </PropertyGroup>
    </Project>
    
  • Launch.exe:
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net6.0</TargetFramework>
      </PropertyGroup>
    
      <ItemGroup>
        <ProjectReference Include="..\QuickSort\QuickSort.csproj" />
      </ItemGroup>
    </Project>
    

On souhaite modifier le framework cible de QuickSort.dll pour cibler .NET Standard plutôt qu’un framework précis. Si on regarde le tableau de compatibilité sur learn.microsoft.com/en-us/dotnet/standard/net-standard, on peut voir que .NET 6.0 implémente toutes les versions de .NET Standard:

.NET 6.0 implémente toutes les versions de .NET Standard

De façon à être compatible avec le framework .NET historique, on va cibler .NET Standard 2.0 (le framework .NET historique n’implémente pas .NET Standard 2.1).

Pour changer le framework cible dans Visual Studio:

  1. Clique droit sur le projet puis Properties
  2. Dans la partie ApplicationGeneralTarget Framework

    Comme on peut le voir, on ne voit pas .NET Standard. Cela s’explique par le fait que .NET Standard est abandonné et Visual Studio n’expose pas directement la possibilité de sélectionner .NET Standard. On peut toutefois le faire en faisant:

    • Un clique droit sur le projet puis Edit project file.
    • Il faut modifier le fichier .csproj en remplaçant net6.0 par netstandard2.0 pour le paramètre TargetFramework:
      <Project Sdk="Microsoft.NET.Sdk">
        <PropertyGroup>
          <OutputType>Library</OutputType>
          <TargetFramework>netstandard2.0</TargetFramework>   
          <Nullable>disable</Nullable>
        </PropertyGroup>
      </Project>
      

      netstandard2.0 est un moniker du framework à cibler pour .NET Standard 2.0. On peut voir d’autres exemples de monikers sur learn.microsoft.com/en-us/dotnet/standard/frameworks.

Si on compile le projet QuickSort en l’état, on peut obtenir des erreurs dues à certaines fonctionnalités n’étant pas compatibles avec certaines versions de C#, par exemple:

Feature 'global using directive' is not available in C# 7.3. Please use language version 10.0 or greater.

On peut résoudre ce problème en désactivant les fonctionnalités incompatibles en rajoutant dans le .csproj:

<ImplicitUsings>disable</ImplicitUsings>

La compilation réussit puisque Launcher.exe cible .NET 6.0 qui est compatible avec .NET Standard 2.0.
Si on change le framework cible de Launcher.exe en choisissant le framework .NET historique (moniker net48):

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net48</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference Include="..\QuickSort\QuickSort.csproj" />
  </ItemGroup>
</Project>

La compilation réussit toujours puisque le framework .NET 4.8 est compatible avec .NET Standard 2.0.

Construction de .NET Standard

Pour comprendre la façon dont .NET Standard est construit, voir Comprendre .NET Standard en 5 min.

Comme on peut le voir, compiler du code historique pour cibler .NET Standard nécessite de modifier les fichiers .csproj et de chercher un compromis dans le choix de la bonne version de .NET Standard. Ces étapes peuvent être couteuses en refactoring dans des projets de grande taille.

Compatibility shim

Utiliser .NET Standard n’est pas la seule façon d’utiliser des assemblies ne ciblant pas le même framework. Les compatibility shims ont régulièrement été utilisés par Microsoft pour assurer une compatibilité ascendante de ses frameworks:

  • Entre la version 2.0 et 4.0 du framework .NET historique,
  • Entre le framework .NET historique et .NET Core 2.0 avec Windows Compatibility Pack,
  • Entre le framework .NET historique 4.8 et .NET.

On va s’intéresser spécifiquement à ce dernier cas. Le but étant de pouvoir utiliser une assembly ciblant le framework .NET historique dans une application .NET sans recompilation et sans modifier le framework cible. Dans ce cas, la compatibility shim utilisée consiste à effectuer des redirections de types (i.e. Type forwarding). On va détailler cette approche par la suite.

De façon à expliciter l’approche des compatibility shims, on se propose de croiser l’utilisation d’assemblies ne ciblant pas les mêmes frameworks:

  • En consommant une assembly ciblant le framework .NET historique dans une application .NET et
  • En consommant une assembly ciblant .NET dans une application ciblant le framework .NET historique.

Utiliser des assemblies framework .NET dans une application ciblant .NET

Cette approche consiste à vérifier s’il est possible de charger une dépendance ciblant le framework .NET historique à partir d’une application .NET. Comme on a pu le voir précédemment, le fonctionnement du CLR entre le framework .NET historique et .NET est très similaire, les grandes différences résident dans les chemins utilisés pour la recherche des dépendances. D’autre part, on a pu observer qu’il n’y a pas de différences dans la structure des assemblies lorsqu’elles ciblent des frameworks différents. La différence réside essentiellement dans les dépendances requises.

C’est essentiellement ce dernier point que Microsoft a tenté de résoudre pour permettre de charger des assemblies ciblant le framework .NET historique dans une application .NET. La compatibility shim utilisée consiste à effectuer une redirection des types (i.e. Type forwarding) du framework historique vers les mêmes types sous .NET.

Si on reprend l’exemple précédent avec les assemblies Launcher.exe et QuickSort.dll . On compile l’assembly QuickSort.dll pour cibler le framework .NET historique:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Library</OutputType>
    <TargetFramework>net48</TargetFramework>
    <ImplicitUsings>disable</ImplicitUsings>
    <Nullable>disable</Nullable>
    <Platforms>AnyCPU;x64</Platforms>
  </PropertyGroup>
</Project>

Si on regarde le manifest de l’assembly avec ildasm.exe (accessible sur C:\Program Files (x86)\Microsoft SDKs\Windows\v10.0A\bin\NETFX 4.8.1 Tools), on peut voir:

// Metadata version: v4.0.30319
.assembly extern mscorlib
{
  .publickeytoken = (B7 7A 5C 56 19 34 E0 89 )             
  .ver 4:0:0:0
}
.assembly QuickSort
{
  .custom instance void [mscorlib]System.Runtime.CompilerServices
    .CompilationRelaxationsAttribute::.ctor(int32) = ( 01 00 08 00 00 00 00 00 ) 
  .custom instance void [mscorlib]System.Runtime.CompilerServices
    .RuntimeCompatibilityAttribute::.ctor() = ( 01 00 01 00 54 02 16 57 72 61 70 4E 6F 6E 45 78   
                                                       63 65 70 74 69 6F 6E 54 68 72 6F 77 73 01 )     

   // ...
  .hash algorithm 0x00008004
  .ver 1:0:0:0
}

L’assembly possède une dépendance vers mscorlib.dll qui est l’assembly de base de framework .NET historique.

Ensuite on compile l’assembly Launcher.exe en mode “self-containded”:

  1. Faire un clique droit sur le projet Launcher puis cliquer sur Publish
  2. Cliquer sur Show all settings puis indiquer les paramètres:
    • Configuration: Release | Any CPU
    • Target Framework: net6.0
    • Deployment mode: Self-contained
    • Target runtime: win-x64
  3. Cliquer sur Save puis Publish

Dans le répertoire de sortie, on peut constater qu’il contient toutes les dépendances. Si on ouvre l’assembly mscorlib.dll avec ildasm, on constate dans le manifest:

// Metadata version: v4.0.30319
.assembly extern System.Private.CoreLib
{
  .publickeytoken = (7C EC 85 D7 BE A7 79 8E )             
  .ver 0:0:0:0
}
// ...
.class extern forwarder System.Object
{
  .assembly extern System.Private.CoreLib
}
// ...

L’assembly mscorlib.dll n’est pas une vraie assembly. Il n’y a pas d’implémentation concernant les objets. Ainsi, l’objet System.Object ne possède pas d’implémentation dans cette assembly. En revanche, elle contient des redirections de types. L’objet System.Object est redirigé vers l’assembly System.Private.CoreLib. Cette assembly fait partie de .NET et contient des types de base du framework.

Si on exécute l’application, on constate qu’elle s’exécute normalement. La dépendance QuickSort.dll est bien chargée malgré le fait qu’elle cible le framework .NET historique. Les redirections de type permettent de substituer les types du framework .NET historique vers les types correspondant dans le .NET.

Ainsi le gros avantage de cette approche est qu’elle ne nécessite pas de recompilation ni de changement de framework cible (comme pour l’approche .NET Standard). Il faut, toutefois, avoir en tête que cette compatibility shim n’est pas infaillible: il peut subsister des différences entre une fonction appelée dans le framework .NET historique et la fonction se trouvant dans l’assembly vers laquelle les redirections sont effectuées. La moindre différence de signature entraînera une exception à l’exécution. D’autre part, plus .NET évolue et plus il s’écarte du framework .NET historique rendant les probabilités d’incompatibilités de plus en plus importantes.

Cette approche est donc plus risquée que .NET Standard puisque les problèmes d’incompatibilités ne peuvent être constatés qu’à l’exécution et non durant la compilation. Il faut donc bien tester l’exécution du code en essayant de balayer tous les scénarios d’appels de fonction utilisant une redirection de type.

Utiliser des assemblies .NET dans une application framework .NET

Cette approche consiste à effectuer l’inverse de ce que nous avons expérimenté précédemment. Nous cherchons à exécuter une application ciblant le framework .NET historique et à charger une assembly ciblant .NET.

Ainsi de façon à ne pas avoir d’erreurs de compilation, on compile Launcher.exe et QuickSort.dll en ciblant le framework .NET historique:

  1. On modifie le fichier .csproj de Launcher.exe de cette façon:
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <Platforms>AnyCPU;x64</Platforms>
        <TargetFramework>net48</TargetFramework>
      </PropertyGroup>
    
      <ItemGroup>
        <ProjectReference Include="..\QuickSort\QuickSort.csproj" />
      </ItemGroup>
    </Project>
    
  2. De même pour QuickSort.dll:
    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Library</OutputType>
        <TargetFramework>net6.0</TargetFramework> 
        <ImplicitUsings>disable</ImplicitUsings>
        <Nullable>disable</Nullable>
        <Platforms>AnyCPU;x64</Platforms>
      </PropertyGroup>
    </Project>
    
  3. L’application devrait compiler normalement et le répertoire de sortie devrait contenir les fichiers:
    • Launcher.exe
    • Launcher.exe.config
    • Launcher.pdb
    • QuickSort.dll
    • QuichSort.pdb
  4. On publie QuickSort.dll pour cibler .NET en mode “self-contained” de façon à ce que le répertoire de sortie contienne toutes les dépendances .NET. On effectue un clique droit sur le projet QuickSort puis on clique sur Publish
  5. Cliquer sur Show all settings puis indiquer les paramètres:
    • Configuration: Release | Any CPU
    • Target Framework: net6.0
    • Deployment mode: Self-contained
    • Target runtime: win-x64
  6. Cliquer sur Save puis Publish

    Dans le répertoire de sortie, on peut constater qu’il contient toutes les dépendances.

  7. Copier toutes les dépendances du répertoire de sortie dans le répertoire de sortie de Launcher.exe.

Si on exécute l’application, on obtient l’erreur suivante:

Unhandled Exception: System.TypeLoadException: Could not load type 'System.Object' from assembly 'System.Private.CoreLib, Version=7.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e' because the parent does not exist.

Ceci s’explique par le chargement du type System.Object à partir de 2 emplacements différents.
Si on regarde le code IL du constructeur .ctor : void () de la classe Example.Launcher.TestClass de l’assembly Launcher.exe avec ildasm.exe:

.method public hidebysig specialname rtspecialname 
    instance void  .ctor() cil managed
{
  // Code size     7 (0x7)
  .maxstack  8
  IL_0000:  ldarg.0
  IL_0001:  call     instance void [mscorlib]System.Object::.ctor()
  IL_0006:  ret
} // end of method TestClass::.ctor

On peut voir que le type System.Object est chargé à partir de l’assembly mscorlib.dll (appartenant au framework .NET historique).

Si on regarde le code IL du constructeur .ctor : void() de la classe Example.QuickSort de l’assembly QuickSort.dll avec ildasm.exe:

.method public hidebysig specialname rtspecialname 
    instance void  .ctor(int32 numberCount) cil managed
{
  // Code size     51 (0x33)
  .maxstack  3
  .locals init (class [System.Runtime]System.Random V_0,
       int32 V_1)
  IL_0000:  ldarg.0
  IL_0001:  call     instance void [System.Runtime]System.Object::.ctor()
  IL_0006:  ldarg.0
  // ...
} // end of method QuickSort::.ctor

On constate que le type System.Object est chargé à partir de l’assembly System.Runtime (appartenant au framework .NET).

Ainsi au démarrage de l’application, le CLR charge le type System.Object à partir de mscorlib.dll. Plus tard dans l’exécution de l’application pour le constructeur de la classe Example.QuickSort, il tente d’utiliser le type System.Object à partir de System.Runtime, or si ce type est déjà chargé toutefois il provient de 2 assemblies différentes d’où l’erreur à l’exécution.

Dans le message d’erreur "Could not load type 'System.Object' from assembly 'System.Private.CoreLib [...]' because the parent dœs not exist.", on peut se demander ce que signifie le terme “parent” ? Le code du CLR permet de comprendre ce terme: dans la fonction QuickSort.dllClassLoader::LoadApproxParentThrowing (le message de l’erreur est IDS_CLASSLOAD_PARENTNULL), on comprend que “parent” désigne le parent de tous les objets en .NET c’est-à-dire System.Object. Lors de l’exécution de ce code, le CLR part du principe que tous les objets en .NET ont pour parent System.Object dans mscorlib.dll or l’objet System.Object dans System.Private.CoreLib ne possède pas pour parent System.Object dans mscorlib.dll. La recherche du parent échoue pour System.Object dans System.Private.CoreLib d’où l’erreur.

Par suite l’objet System.Object dans System.Private.CoreLib ne peut être chargé. Ce problème ne permet pas une exécution de cette façon, ce qui rend incompatible les assemblies .NET avec les applications du framework .NET historique. Cela peut se comprendre puisque ce use-case n’a pas un réel intérêt fonctionnel (compatibilité descendante). Microsoft n’a donc pas cherché à le résoudre.

Conclusion

Pour une application, lorsque le volume de code existant est important, il est compliqué de savoir quelle est la meilleure approche pour faire évoluer les différentes dépendances et éviter une trop grande dette technique. Bien-que source d’instabilité, dans le milieu des années 2010, il aurait été inconcevable pour Microsoft de continuer à faire évoluer .NET sans proposer une réelle rupture en ouvrant cette technologie à d’autres plateformes que Windows. Problématique pour beaucoup d’équipes de développeurs, cette rupture tend à se stabiliser. En effet, actuellement Microsoft, a terminé sa migration du framework .NET historique vers .NET:

  • Le framework .NET historique n’évolue, désormais, plus fonctionnellement,
  • .NET Standard qui était l’approche de transition entre le framework .NET historique et .NET, est abandonné,
  • Le renommage de .NET Core en .NET qui était l’appellation initiale du framework .NET historique achève de tourner la page du framework historique.

Même si la période de transition entre l’arrêt du framework .NET historique et la poursuite des nouvelles fonctionnalités exclusivement sur .NET est maintenant terminée, elle aura duré 4 ans entre la 1ère version de .NET Core en juin 2016 et l’annonce de l’abandon de .NET Standard en septembre 2020. Durant 4 ans, pour tous les développeurs .NET s’est posé la douloureuse question de savoir comment faire évoluer son application:

  • Continuer à développer en ciblant le framework .NET historique en 4.8,
  • Migrer en ciblant .NET Standard,
  • Migrer en ciblant .NET Core puis .NET.

Pour encore beaucoup d’applications, la masse de code peut rendre cette évolution compliquée et couteuse. Dans ce optique, cet article avait pour but de montrer que l’approche .NET n’est pas complètement différente de l’approche du framework .NET historique:

  • Les assemblies ont la même structure entre les différents frameworks,
  • Suivant le framework ciblé, la plus grande différence dans les assemblies réside dans les dépendances,
  • Le CLR se comporte de la même façon pour charger des dépendances même si les répertoires de recherche des dépendances ne sont pas les mêmes.
  • Certaines fonctionnalités comme les AppDomains sont différentes entre le framework .NET historique et .NET.
  • Des compatibility shims permettent de continuer à utiliser des assemblies ciblant le framework .NET historique dans des applications .NET au prix d’effectuer des tests à l’exécution pour minimiser le risque d’exception en cas d’incompatibilité.
  • Ces compatibility shims permettent d’éviter de devoir recompiler ou changer la cible d’assemblies historiques.

J’espère que cet article vous aura aider à appréhender plus facilement le passage du framework .NET historique vers .NET.

Références

(*)Abandon .NET Standard:

Changement des assemblies:

App host:

AssemblyLoadContext

Affichage des dépendances

Type forwarding:

Assembly structure:

Autres:

Système d’import de modules Python


Cet article fait partie d’une série d’articles sur la syntaxe de base Python.

Le but de cet article est d’expliquer les imports de modules Python (les modules d’extension ne seront pas abordés).
Dans un premier temps, on va décrire brièvement le fonctionnement du système d’import de modules. Ensuite, on va compléter cette description avec un exemple. Enfin, on va rappeler la syntaxe pour importer un module et obtenir des informations sur un module importé.

Les seuls objets Python sont les modules quelque soit leur implémentation:

  • les modules purs implémentés en Python sous la forme de fichier .py,
  • les modules d’extensions en C,
  • etc…

Les packages sont des objets Python pouvant s’apparenter à des répertoires. Ces packages contiennent des sub-packages ou des modules. Un package est un module avec un attribut __path__.


Il existe une différence entre l’identification des modules et packages Python et le chemin des fichiers ou répertoires correspondant dans le système de fichiers. Ainsi il faut considérer les modules et les packages comme étant un type d’objet plutôt que comme des fichiers ou des répertoires.

Par suite, les règles suivantes s’appliquent pour identifier des modules et packages par rapport aux fichiers correspondants:

  • Un module est identifié par le nom du fichier sans l’extension .py: si le fichier s’appelle hello_world.py alors le module s’appellera hello_world.
  • Un package est identifié de la même façon qu’un répertoire avec son nom.
  • Un sub-package est identifié à partir de ses packages parents suivant son chemin: ainsi si le sub-package est dans le répertoire numpy/polynomial alors le nom du sub-package sera numpy.polynomial.
  • Un module dans un package: par exemple si on considère un module sous la forme d’un fichier .py dans le répertoire numpy/polynomial/chebyshev.py alors l’identifiant du module sera numpy.polynomial.chebyshev.

Fonctionnement du système d’import

Un import consiste à effectuer 2 opérations:

  • La recherche d’un module nommé et
  • L’attribution d’un nom à ce module, la portée de ce nom étant local.

Mécanisme d’import des modules

L’utilisation de l’instruction import correspond à un appel à la fonction __import__() puis à l’attribution d’un nom local aux modules importés. Cette attribution se fait en rajoutant une entrée dans le dictionnaire sys.module. Ce dictionnaire permet de retrouver un module en fonction de son nom.

L’exécution de la fonction __import__() déclenche une série d’étapes qui ont pour but de trouver le module à charger:

  1. Suivant le nom du module, une recherche est effectuée pour déterminer l’emplacement du module en fonction de son nom.
  2. Avant d’effectuer une recherche à proprement parlé, une recherche est faite en utilisant le dictionnaire sys.modules contenant les modules en fonction du nom. Si le module n’est pas trouvé dans ce dictionnaire, la recherche se poursuit.
  3. La recherche est effectuée par des objets appelés finders ou des importers. D’autres objets appelés loaders permettent de charger les modules. Les importers sont à la fois des finders et des loaders.

    Par défaut, Python contient un certain nombre de finders et importers en parcourant sys.meta_path. Tous les objets de sys.meta_path sont appelés successivement pour trouver le module.

    Pour voir le contenu du tableau sys.meta_path, il suffit d’exécuter:

    >>> import sys
    >>> sys.meta_path
    [_frozen_importlib.BuiltinImporter,
    _frozen_importlib.FrozenImporter,
    _frozen_importlib_external.PathFinder,
    <six._SixMetaPathImporter at 0xffff90cc1fa0>,
    <pkg_resources.extern.VendorImporter at 0xffff90a7ceb0>]
    

    On peut voir que la recherche est effectuée sur les modules “built-in” c’est-à-dire des modules écrit en C faisant partie du shell Python. Dans un 2e temps, la recherche s’effectue pour des modules “frozen”. Les modules “frozen” sont des modules compilés en byte-code exécutables sans l’utilisation de Python. Dans 3e temps, une correspondance est cherchée entre le nom du module et son emplacement suivant le chemin d’import (i.e. import path). La liste des emplacements du chemin d’import peut être listée en affichant le contenu de sys.path (les chemins dépendent de la distribution Python, de l’environnement et du contexte d’exécution). 2 autres variables peuvent être utilisées pour trouver le chemin d’un module sys.path_hooks et sys.path_importer_cache.

    L’emplacement d’un module n’est pas forcément sur le disque, il pourrait être dans une archive .zip ou accessible avec une URL.

    Lorsqu’un finder trouve la correspondance entre un module et son nom, il retourne le loader correspondant. Si le finder est un importer, il retourne sa propre instance.

  4. Le module est rajouté dans sys.modules avant son exécution. Si l’exécution du module échoue, il est retiré du dictionnaire sys.modules. L’ajout dans sys.modules est faite au préalable car l’exécution du module peut l’amener à se charger lui-même ce qui pourrait provoquer une boucle récursive infinie en le rajoutant indéfiniment dans sys.modules.
  5. Le loader correspondant exécute le module (en appelant la méthode exec_module()). Les modules peuvent se trouver dans des packages. Un package est lui-même un module avec un attribut __path__ indiquant le chemin du package.

    Il existe 2 types de packages:

    • Regular packages: ils sont implémentés dans un répertoire contenant un fichier __init__.py. Ce fichier est exécuté implicitement à chaque import de ce type de packages. Les sub-packages c’est-à-dire les répertoires enfant doivent aussi contenir des fichiers __init__.py qui, en cas d’import, seront exécutés après le __init__.py du répertoire parent.
    • Namespace packages: ces packages sont constitués de portions pouvant se trouver à des emplacements différents dans le système de fichier. Par exemple, une portion peut être un fichier sur le disque et un autre peut être à un emplacement sur le réseau.

Attributs des modules

Lors de l’import de modules, des objets correspondant sont créés pour accéder aux fonctions dans les modules. Le mécanisme d’import ajoute quelques attributs indiquant d’où provient le module:

  • __name__: le nom du module
  • __loader__: nom du loader utilisé pour importer le module. Les finders et loaders disponibles peuvent être listés avec sys.meta_path.
  • __package__: dans le cas d’un package cet attribut possède la même valeur que __name__.
  • __spec__: ensemble des spécifications utilisées pour le système d’import.
  • __path__: chemin du package sur le disque. Ce chemin dépend de l’environnement virtuel utilisé.
  • __file__: fichier __init__.py utilisé itinialement. Ce chemin dépend de l’environnement virtuel utilisé.
  • __cached__: fichier compilé (voir CPython) utilisé à l’exécution. Ce chemin dépend de l’environnement virtuel utilisé.

En utilisant la fonction dir(), on peut voir la liste des attributs d’un module.

Chemin des modules dans les packages

Dans les packages, les imports de modules se font avec un chemin relatif au module courant ou en indiquant le chemin à partir du répertoire parent. Comme indiqué précédemment, les modules sont identifiés par nom. Dans le cas de modules présents sur le disque, le nom permet d’indiquer l’emplacement physique du fichier .py correspondant. Le nom des modules à importer doit respecter les règles suivantes:

  • Le nom ne doit pas comporter de / ou \ (à la différence d’un chemin de fichier), ils doivent être remplacés par ..
  • . permet d’indiquer le répertoire courant.
  • .. permet d’indiquer le répertoire parent.
  • Le nom du module correspond au nom du fichier correspondant sans l’extension .py.

Ainsi si on veut indiquer le chemin de:

  • ./Module1.py alors l’import peut se faire avec from .Module1 import *
  • ./InnerModule/Module3.py alors l’import peut se faire avec from .InnerModule.Module3 import *
  • ../Module2.py l’import peut se faire avec from ..Module2 import *

Par exemple si on considère les fichiers suivants:

Hello
├── InnerModule
│ ├── __init__.py
│ └── Module3.py
├── __init__.py
├── Module1.py
├── Module2.py
└── setup.py

Le contenu des fichiers est:

  • Hello/Module1.py:
    from Hello.Module2 import HelloFromModule2
    
    def HelloFromModule1():
        print("Hello from module 1")
        HelloFromModule2()
    
  • Hello/Module2.py:
    from Hello.InnerModule.Module3 import HelloFromModule3
    
    def HelloFromModule2():
        print("Hello from module 2")
        HelloFromModule3()
    
  • Hello/InnerModule/Module3.py:
    def HelloFromModule3():
        print("Hello from module 3")
    

Dans ces exemples, les imports ont été faits suivant le répertoire parent du package. Si on avait indiqué les chemins de façon relative, il aurait fallu effectuer les imports de cette façon:

  • Hello/Module1.py:
    from .Module2 import HelloFromModule2
    
  • Hello/Module2.py:
    from .InnerModule.Module3 import HelloFromModule3
    
Code source et exécution

On peut voir les fichiers de cet exemple dans le repository GitHub: github.com/msoft/python_example_module_import.

Il est préférable de créer un environnement virtuel avant de continuer. Pour créer un environnement virtuel, on peut exécuter:

~/python_example_module_import% python -m venv venv
~/python_example_module_import% source venv/bin/activate

On peut ensuite installer les packages nécessaires pour construire le package:

(venv) ~/python_example_module_import/package_creation% pip install setuptools wheel

Pour construire le package .whl, il faut exécuter dans le répertoire package_creation:

(venv) ~/python_example_module_import/package_creation% python setup.py bdist_wheel

Le package sera créé dans le répertoire: python_example_module_import/package_creation/dist.

Pour installer le package, il suffit d’exécuter:

(venv) ~/python_example_module_import/package_creation/dist% pip install Hello-1.0-py3-none-any.whl

Les fichiers du package se trouvent dans un répertoire du type: python_example_module_import/venv/lib/python3.9/site-packages/Hello.

Enfin pour utiliser le package installé, on peut exécuter le fichier python_example_module_import/package_usage/test.py:

(venv) ~/python_example_module_import/package_usage% python test.py
Hello from module 1
Hello from module 2
Hello from module 3

Comment créer un package .whl ?

Pour créer un package, on peut utiliser un fichier setup.py et le package setuptools, voir Construire un package wheel.

Encapsulation

L’encapsulation au niveau de la syntaxe du langage à proprement parlé n’existe pas en Python. Toutefois il est possible de ne pas exposer des modules lorsqu’ils sont dans des packages. En effet dans les fichiers __init__.py des répertoires du packages, on peut indiquer les modules à importer lorsque le package est importé. Ainsi suivant les imports qui y sont effectués, on peut choisir d’exposer des modules particuliers ou de ne pas en exposer d’autres à l’extérieur.

Par exemple, si on considère l’exemple précédent, dans le fichier Hello/__init__.py, si on ne souhaite exposer que le Module1 alors on peut effectuer l’import de cette façon:

from .Module1 import HelloFromModule1

Dans ce cas à l’extérieur du package, on pourra effectuer l’import de cette façon:

import Hello as h
h.HelloFromModule1()

Le module Module2 n’étant pas exposé dans le fichier Hello/__init__.py, il n’est pas accessible de l’extérieur:

h.HelloFromModule2() # ERREUR

Pour que HelloFromModule2() soit accessible, il faut l’importer en modifiant Hello/__init__.py de cette façon:

from .Module1 import HelloFromModule1
from .Module2 import HelloFromModule2

Import d’un package

Comme indiqué précédemment:

  • Les seuls objets Python sont les modules.
  • Les packages sont des modules avec un attribut __path__.

Le système d’import a donc le même comportement qu’ils s’agissent de modules ou de packages. Il faut toutefois faire attention à la syntaxe utilisée lors de l’import. Ainsi les imports peuvent se faire:

  • Relativement au fichier courant ou
  • Par rapport au répertoire initial du package.

La recherche d’un module se fait en utilisant son nom (cf. Mécanisme d’import des modules). Le système d’import parcourt les répertoires du Python path pour trouver le module suivant son nom. Dans un premier temps, on va montrer comment récupérer les répertoires du Python path. Dans un 2e temps, on va indiquer la syntaxe d’import des modules et packages.

Python path

Le Python path est un tableau indiquant les chemins parcourus par le système d’import pour trouver un module suivant son nom. Pour obtenir cette liste de répertoire, il faut exécuter:

>>> import sys
>>> sys.path

Le résultat dépend du système d’exploitation et de la distribution Python utilisé toutefois le tableau devrait contenir en particulier:

  • Le répertoire courant,
  • Le répertoire de l’exécutable python: par exemple <répertoire d'Anaconda/lib/python3.9.
  • Le répertoire des packages: par exemple <répertoire d'Anaconda/lib/python3.9/site-packages.
  • Le répertoire des packages dans le cas d’un environnement virtuel: par exemple <répertoire env. virtuel/lib/python3.9/site-packages.

On peut voir les modules déjà importés en exécutant:

>>> sys.modules

Import de modules

Un module possède un namespace privé et ce namespace n’est pas directement accessible à l’extérieur du module. Un module peut importer un autre module.

Plusieurs syntaxes sont possibles pour importer un module:

  • import <nom du module>: le module est importé dans le namespace local toutefois tous les noms des objets ne sont pas accessibles à partir du namespace local. Pour accéder aux objets du module, il faut taper <nom du module>.<nom de l'objet>.

    Par exemple:

    import pandas
    data = pandas.DataFrame()
    
  • import <nom de l'objet> as <nom alias>: permet d’éviter d’utiliser le nom entier du module pour accéder à ses objets. Avec cette syntaxe, le module est importé dans le namespace local toutefois les objets ne sont accessibles qu’en utilisant l’alias du module: <nom alias>.<nom de l'objet>.

    Par exemple:

    import pandas as pd
    data = pd.DataFrame()
    
  • from <nom du module> import <nom de l'objet>: on ne charge qu’un seul objet du module dans le namespace local. Cet objet est accessible en utilisant directement son nom.

    Par exemple:

    from pandas import DataFrame
    data = DataFrame()
    
  • from <nom du module> import *: tous les noms des objets du module sont importés dans le namespace local. Il n’est pas recommandé d’utiliser cette syntaxe car il peut y avoir des collisions entre des modules qui utiliseraient les mêmes noms d’objet. Avec cette syntaxe, les objets sont accessibles directement par leur nom.

    Par exemple:

    from pandas import *
    data = DataFrame()
    
  • from <nom du module> import <nom de l'objet> alias <alias de l'objet>: cette syntaxe permet d’importer le nom d’un objet du module et de permettre d’utiliser cet objet en utilisant un alias.

    Par exemple:

    from pandas import DataFrame as PandasDataframe
    data = PandasDataframe()
    

Avoir des informations sur un module importé

La fonction dir() permet de lister les noms d’objets définis dans le namespace local. Cette fonction permet de lister les variables, les fonctions et les modules.

Ainsi:

  • dir(): sans argument affiche les noms de variables, fonctions et modules qui sont accessibles dans le namespace local.
  • dir(<nom du module>): liste les objets accessibles dans le module.

Par exemple, si on importe le package numpy:

>>> import numpy as np
>>> dir(np)
['ALLOW_THREADS',
'AxisError',
'BUFSIZE',
'Bytes0',
'CLIP',
'ComplexWarning',
'DataSource',
'Datetime64',
'ERR_CALL',
'ERR_DEFAULT',
'ERR_IGNORE',
'ERR_LOG',
'ERR_PRINT',
'ERR_RAISE',
'ERR_WARN',
'FLOATING_POINT_SUPPORT',
'FPE_DIVIDEBYZERO',
'FPE_INVALID',
'FPE_OVERFLOW',
'FPE_UNDERFLOW',
...,
'True_',
'UFUNC_BUFSIZE_DEFAULT',
'UFUNC_PYVALS_NAME',
'Uint64',
'VisibleDeprecationWarning',
'WRAP',
'_NoValue',
'_UFUNC_API',
'__NUMPY_SETUP__',
'__all__',
'__builtins__',
'__cached__',
'__config__',
'__deprecated_attrs__',
'__dir__',
'__doc__',
'__expired_functions__',
'__file__',
'__getattr__',
'__git_revision__',
'__loader__',
'__name__',
'__package__',
'__path__',
'__spec__',
'__version__',
'_add_newdoc_ufunc',
'_distributor_init',
'_financial_names',
'_globals',
'_mat',
'_pytesttester',
'abs',
...,
'fft',
'square',
...,
'uint',
'uint0',
'uint16',
'uint32',
'uint64',
'uint8',
...,
'where',
'who',
'zeros',
'zeros_like']

Ainsi parmi les objets listés, il peut y avoir:

  • Des constantes:
    >>> print(np.ERR_LOG)
    5
    
  • Des fonctions:
    >>> print(np.sum)
    <function sum at 0xffff8c103160>
    
  • Des modules:
    >>> print(np.fft)
    <module 'numpy.fft' from ‘<chemin environnement virtuel>/site-packages/numpy/fft/__init__.py'>
    
  • Des attributs:
    >>> print(np.__package__)
    numpy
    
  • Des classes:
    >>> print(np.uint)
    <class 'numpy.uint64'>
    

Dataframes Pandas

Cet article fait partie d’une série d’articles sur la syntaxe de base Python.

Un dataframe pandas est une structure de données mutable que l’on peut considérer comme un dictionnaire de séries pandas. Les colonnes sont accessibles en utilisant le nom de la colonne en tant que clé du dictionnaire. La valeur extraite avec la clé est une série qui peut avoir un index personnalisé ou non. Les valeurs d’un dataframe peuvent être de type différent toutefois il est possible d’avoir un type par colonne.

Sommaire

Initialisation
A partir d’une liste
A partir d’une liste de listes
A partir d’un dictionnaire de listes
A partir d’une liste de dictionnaires
A partir d’un dictionnaire de séries pandas
A partir d’objets de types différents
Indiquer explicitement des index (argument index)
Indiquer explicitement les colonnes (argument columns)
Indiquer explicitement le type des valeurs (argument dtype)
Initialiser sans effectuer de copies (argument copy)

Lire le contenu d’un dataframe
Accéder à une valeur à partir de l’index
  loc[] et iloc[]
Sous-ensembles
at[] et iat[]

Multi-index
Accès aux éléments en utilisant un multi-index
Utilisation de multi-index pour les lignes
Création de multi-index à partir d’un tableau
Création de multi-index à partir de tuples
Création de multi-index en effectuant un produit cartésien
Création de multi-index à partir d’un dataframe
Création de multi-index avec les arguments levels et codes
Nommer les niveaux du multi-index

Modifier un dataframe
Modifier les colonnes ou lignes
  Renommer des lignes ou colonnes
  Ajouter une colonne
  Changer l’ordre des colonnes
  Supprimer des colonnes
  Supprimer des lignes
Modification de valeurs d’un dataframe
  Modifier les valeurs d’un dataframe par condition
  Modifier un dataframe en appliquant des fonctions

Agrégation
Avec <dataframe>.groupby()
Fonction d’agrégation aggregate() ou agg()

Fonctions mathématiques
Opérations sur les dataframes
Opérations entre un dataframe et un autre type d’objet
  Argument fill_value
Opérations avec des index ou des colonnes non communs

Jointures

Concaténation

Le but de cet article est de présenter les fonctionnalités de base des dataframes pandas.

On peut importer pandas de cette façon pour utiliser les objets dans la bibliothèque:

import pandas as pd

Initialisation

On peut créer un dataframe à partir d’une liste, d’un dictionnaire de listes, d’une liste de dictionnaires ou d’un dictionnaire de séries pandas.

A partir d’une liste

A partir d’une liste, le dataframe ne contient qu’une seule colonne correspondant aux éléments de la liste. Les lignes possèdent un index par défaut non personnalisé, par exemple:

>>> data = [1, 3, 5, 7, 9, 11]
>>> df = pd.DataFrame(data)
>>> print(df)
    0
0   1
1   3
2   5
3   7
4   9
5  11

Avec cet exemple, le dataframe ne contient qu’une seule colonne '0'. Les index des lignes sont les index par défaut. Sans précision, le type est déterminé en fonction du type des valeurs de la liste:

>>> df.dtypes
0    int64
dtype: object

A partir d’une liste de listes

A partir d’une liste de listes, le dataframe contiendra les listes disposées en colonne. Les lignes possèdent un index par défaut non personnalisé.

Par exemple:

>>> data = [[1, 3, 5], ['1', '3', '5'], [1.0, 3.0, 5.0]]
>>> df = pd.DataFrame(data)
>>> print(df)
     0    1    2
0    1    3    5
1    1    3    5
2  1.0  3.0  5.0

Dans cet exemple, les types des objets sont différents pour une même colonne donnée. Ainsi si on regarde le type des objets, ils seront de type object (qui correspond au type le plus large entre les int64 et les float64):

>>> df.dtypes
     0    object
     1    object
     2    object
     dtype: object

A partir d’un dictionnaire de listes

A partir d’un dictionnaire de listes:

  • Les clés du dictionnaire correspondent aux colonnes du dataframe et
  • Les listes seront les colonnes du dataframe.

Par exemple:

>>> data = {'a': [1, 3, 5, 7, 9, 11], 'b': ['1', '3', '5', '7', '9', '11'], 'c': [1.0, 3.0, 5.0, 7.0, 9.0, 11.0]}
>>> df = pd.DataFrame(data)
>>> print(df)
    a   b     c
0   1   1   1.0
1   3   3   3.0
2   5   5   5.0
3   7   7   7.0
4   9   9   9.0
5  11  11  11.0

Le type des colonnes est déduit du type des éléments dans les listes:

>>> df.dtypes
a      int64
b     object
c    float64
dtype: object

A partir d’une liste de dictionnaires

Dans le cas d’une liste de dictionnaires:

  • Les clés des éléments dans les dictionnaires correspondent aux colonnes du dataframe,
  • Les valeurs des éléments dans les dictionnaires correspondent aux lignes du dataframe.

Par exemple:

>>> data = [{'a': 1, 'b': '1', 'c': 1.0}, {'a': 3, 'b': '3', 'c': 3.0}, {'a': 5, 'b': '5', 'c': 5.0},
       {'a': 7, 'b': '7', 'c': 7.0}, {'a': 9, 'b': '9', 'c': 9.0}]
>>> df = pd.DataFrame(data)
>>> print(df)
   a  b    c
0  1  1  1.0
1  3  3  3.0
2  5  5  5.0
3  7  7  7.0
4  9  9  9.0

Le type des colonnes est déduit du type des éléments dans les dictionnaires:

>>> df.dtypes
a      int64
b     object
c    float64
dtype: object

Dans le cas où tous les dictionnaires ne contiennent pas toutes les clés, les valeurs manquantes sont remplacées par NaN.

Par exemple, si on considère la liste de dictionnaires suivantes:

>>> data = [{'a': 1, 'b': '1', 'c': 1.0}, {'a': 3}, {'b': '5'}, {'c': 7.0}]
>>> df = pd.DataFrame(data)
     a    b    c
0  1.0    1  1.0
1  3.0  NaN  NaN
2  NaN    5  NaN
3  NaN  NaN  7.0

A partir d’un dictionnaire de séries pandas

Si on crée un dataframe à partir d’un dictionnaire de séries, chaque série correspond à une colonne du dataframe.

Par exemple:

>>> data1 = pd.Series([1, 3, 5, 7, 9])
>>> data2 = pd.Series(['1', '3', '5', '7', '9'])
>>> data3 = pd.Series([1.0, 3.0, 5.0, 7.0, 9.0])
>>> df = pd.DataFrame({'a': data1, 'b': data2, 'c': data3 })
>>> print(df)
   a  b    c
0  1  1  1.0
1  3  3  3.0
2  5  5  5.0
3  7  7  7.0
4  9  9  9.0

Le type des colonnes est déduit du type des éléments dans les séries:

>>> df.dtypes
a      int64
b     object
c    float64
dtype: object

A partir d’objets de types différents

On peut effectuer l’initialisation du dataframe avec des objets de type différent. Par exemple, si on considère une liste et une série:

>>> data = {'a': [1, 3, 5], 'b': pd.Series(['1', '3', '5'])}
>>> df = pd.DataFrame(data)
   a  b
0  1  1
1  3  3
2  5  5

Indiquer explicitement des index (argument index)

On utilise le terme “index” pour désigner les labels utilisés pour identifier les lignes en opposition aux labels utilisés pour les colonnes. Par défaut, les index des éléments sont des entiers à partir de 0. Avec l’argument index, on peut explicitement préciser des index, par exemple:

>>> data = {'a': [1, 3, 5], 'b': ['1', '3', '5'], 'c': [1.0, 3.0, 5.0]}
>>> i = ['one', 'two', 'three']
>>> df = pd.DataFrame(data, i)
       a  b    c
one    1  1  1.0
two    3  3  3.0
three  5  5  5.0

On peut aussi indiquer l’index de cette façon à l’initialisation:

>>> df = pd.DataFrame(data, index=i)

Si la taille de l’index ne correspond pas au nombre de lignes du dataframe, une erreur survient:

>>> data = {'a': [1, 3, 5], 'b': ['1', '3', '5'], 'c': [1.0, 3.0, 5.0]}
>>> i = ['one', 'two', 'three', 'four']
ValueError: Length mismatch: Expected axis has 3 elements, new values have 4 elements

On peut affecter un index particulier après initialisation avec la propriété <dataframe>.index. Il faut que la taille du tableau de l’index soit la même que le nombre de lignes du dataframe.

Par exemple:

>>> data = {'a': [1, 3, 5], 'b': ['1', '3', '5'], 'c': [1.0, 3.0, 5.0]}
>>> df = pd.DataFrame(data)
>>> print(df)
   a  b    c
0  1  1  1.0
1  3  3  3.0
2  5  5  5.0

>>> df.index = i
>>> print(df)
       a  b    c
one    1  1  1.0
two    3  3  3.0
three  5  5  5.0

On peut nommer l’index d’un dataframe avec:

<dataframe>.index.name = <chaine de caractères>

Par exemple:

>>> print(df)
   a  b    c
0  1  1  1.0
1  3  3  3.0
2  5  5  5.0
>>> df.index.name = 'Index name'
>>> print(df)
            a  b    c
Index name           
0           1  1  1.0
1           3  3  3.0
2           5  5  5.0

Indiquer explicitement les colonnes (argument columns)

On peut indiquer les colonnes du dataframe avec l’argument columns. Les noms de colonnes doivent être indiqués sous la forme d’une liste de même taille que le nombre de colonnes:

>>> data = [[1, 3, 5], ['1', '3', '5'], [1.0, 3.0, 5.0]]
>>> c = ['a', 'b', 'c']
>>> df = pd.DataFrame(data, columns=c)
>>> print(df)
     a    b    c
0    1    3    5
1    1    3    5
2  1.0  3.0  5.0

Si le tableau fourni pour le nom des colonnes n’est pas de la même taille que le nombre de colonnes du dataframe, une erreur se produit:

>>> data = [[1, 3, 5], ['1', '3', '5'], [1.0, 3.0, 5.0]]
>>> c = ['a', 'b', 'c', 'd']
>>> df = pd.DataFrame(data, columns=c)
ValueError: 4 columns passed, passed data had 3 columns

On peut affecter la propriété <dataframe>.columns après initialisation pour préciser les noms de colonnes:

>>> data = [[1, 3, 5], ['1', '3', '5'], [1.0, 3.0, 5.0]]
>>> df = pd.DataFrame(data)
>>> print(df)
     0    1    2
0    1    3    5
1    1    3    5
2  1.0  3.0  5.0
>>> c = ['a', 'b', 'c']
>>> df.columns = c
     a    b    c
0    1    3    5
1    1    3    5
2  1.0  3.0  5.0

On peut nommer les colonnes d’un dataframe avec:

<dataframe>.columns.name = <chaîne de caractères>

Par exemple:

>>> print(df)
   a  b    c
0  1  1  1.0
1  3  3  3.0
2  5  5  5.0
>>> df.columns.name = 'Column name'
>>> print(df)
Columns name  a  b    c
0             1  1  1.0
1             3  3  3.0
2             5  5  5.0

Indiquer explicitement le type des valeurs (argument dtype)

Pandas reconnait les types numpy donc on peut utiliser la même syntaxe que pour numpy pour indiquer explicitement un type à l’initialisation, par exemple:

>>> data = {'a': [1, 3, 5], 'b': ['1', '3', '5'], 'c': [1.0, 3.0, 5.0]}
>>> df = pd.DataFrame(data, dtype='f8')     # f8 pour flottant sur 8 octets/64 bits
>>> print(df)
     a    b    c
0  1.0  1.0  1.0
1  3.0  3.0  3.0
2  5.0  5.0  5.0
>>> df.dtypes
a    float64
b    float64
c    float64
dtype: object

Tous les éléments sont convertis en flottants si c’est possible.

Initialiser sans effectuer de copies (argument copy)

Par défaut, quand un dataframe pandas est initialisé à partir d’une série pandas, une copie des éléments est effectuée. Il est possible d’effectuer une initialisation du dataframe en utilisant des références vers les objets de la structure d’origine avec l’argument copy (par défaut, la valeur est copy est True):

>>> data = {'a': pd.Series([1, 2, 3, 5])}
>>> df = pd.DataFrame(data, copy=False)
>>> print(df)
   a
0  1
1  2
2  3
3  5

>>> print(data)
{'a': 0    1
1    2
2    3
3    5
dtype: int64}

>>> df['a'][1] = 1000
>>> print(data)
{'a': 0       1
1    1000
2       3
3       5
dtype: int64}

L’initialisation du dataframe étant faite avec des références, si on modifie une valeur dans le dataframe alors les éléments dans la structure d’origine sont aussi modifiés.

Lire le contenu d’un dataframe

Les attributs et fonctions suivantes permettent d’obtenir des informations sur le dataframe:

  • <dataframe>.dtypes: renvoie une série contenant les types des éléments. L’index de la série correspond aux noms de colonnes du dataframe.
  • <dataframe>.head(): renvoie un dataframe contenant les 5 premières lignes du dataframe. <dataframe>.head(8) renvoie les 8 premières lignes.
  • <dataframe>.tail(): renvoie un dataframe contenant les 5 dernières lignes du dataframe. <dataframe>.tail(8) renvoie les 8 dernières lignes.
  • <dataframe>.info(): retourne des informations concernant le dataframe (noms, types des colonnes; nombre des valeurs non-nulles; mémoire occupée par l’instance du dataframe), par exemple:
    >>> data = {'a': [1, 3, 5], 'b': ['1', '3', '5'], 'c': [1.0, 3.0, 5.0]}
    >>> df = pd.DataFrame(data)
    >>> df.info()
    <class 'pandas.core.frame.DataFrame'>
    RangeIndex: 3 entries, 0 to 2
    Data columns (total 3 columns):
     #   Column  Non-Null Count  Dtype  
    ---  ------  --------------  -----  
     0   a       3 non-null      int64  
     1   b       3 non-null      object 
     2   c       3 non-null      float64
    dtypes: float64(1), int64(1), object(1)
    memory usage: 200.0+ bytes
    
  • <dataframe>.columns: renvoie un objet itérable permettant d’obtenir le nom des colonnes. <dataframe>.columns.values renvoie les noms de colonnes sous la forme d’un tableau numpy.
  • <dataframe>.index: renvoie un objet itérable permettant d’obtenir les index (nom de lignes). <dataframe>.index.values renvoie les noms des index sous la forme d’un tableau numpy.
  • <dataframe>.values: retourne l’ensemble des éléments du dataframe sous la forme d’un tableau numpy.
  • <dataframe>.describe(): renvoie des informations statistiques sur les éléments du dataframe quand les éléments sont des valeurs numériques.

    Par exemple:

    >>> data = {'a': [1, 3, 5], 'b': ['1', '3', '5'], 'c': [1.0, 3.0, 5.0]}
    >>> df = pd.DataFrame(data)
    >>> df.describe()
             a    c
    count  3.0  3.0
    mean   3.0  3.0
    std    2.0  2.0
    min    1.0  1.0
    25%    2.0  2.0
    50%    3.0  3.0
    75%    4.0  4.0
    max    5.0  5.0
    
  • <dataframe>.shape renvoie la taille du dataframe sous la forme d’un tuple.
  • len(<dataframe>) ou len(<dataframe>.index) renvoie le nombre de lignes du dataframe.
  • len(<dataframe>.columns) renvoie le nombre de colonnes du dataframe.
  • <dataframe>.memory_usage(): retourne la taille occupée pour chaque colonne.

Accéder à une valeur à partir de l’index

Comme indiqué auparavant, un dataframe pandas peut être considéré comme un dictionnaire de séries pandas. On peut accéder à chaque série en utilisant les index des colonnes, par exemple si on considère le dataframe:

>>> data = {'a': ['a0', 'a1', 'a2'], 'b': ['b0', 'b1', 'b2'], 'c': ['c0', 'c1', 'c2']}
>>> i = ['l0', 'l1', 'l2']
>>> df = pd.DataFrame(data, index=i)
>>> print(df)
     a   b   c
l0  a0  b0  c0
l1  a1  b1  c1
l2  a2  b2  c2

Alors df['a'] est une série contenant tous les éléments de la colonne 'a':

>>> df['a']
l0    a0
l1    a1
l2    a2
Name: a, dtype: object

On peut accéder directement à un élément en utilisant l’index de la série:

>>> df['a']['l1']
'a1'

loc[] et iloc[]

La fonction loc[] permet d’accéder à un ou plusieurs éléments en utilisant des index sous la forme <index ligne>, <index colonne>, par exemple:

>>> print(df)
     a   b   c
l0  a0  b0  c0
l1  a1  b1  c1
l2  a2  b2  c2

>>> df.loc['l1', 'b']
'b1'

Dans cet exemple, il existe 2 index:

  • L’index personnalisé paramétré à l’initialisation du dataframe:
    • Lignes: list(df.index)['l0', 'l1', 'l2']
    • Colonnes: list(df.columns)['a', 'b', 'c']
  • L’index implicite du dataframe sous la forme numérique.

On peut utiliser loc[] pour les index personnalisés et iloc[] pour les index numériques. S’il n’existe pas d’index personnalisé alors loc[] fonctionne avec des index implicites numériques.

Par exemple:

>>> data1 = {'a': ['a0', 'a1', 'a2'], 'b': ['b0', 'b1', 'b2'], 'c': ['c0', 'c1', 'c2']}
>>> i = ['l0', 'l1', 'l2']
>>> df_custom_indexes = pd.DataFrame(data1, index=i)
>>> print(df_custom_indexes)
     a   b   c
l0  a0  b0  c0
l1  a1  b1  c1
l2  a2  b2  c2

>>> data2 = [['a0', 'b0', 'c0'], ['a1', 'b1', 'c1'], ['a2', 'b2', 'c2']]
>>> df_implicit_indexes = pd.DataFrame(data2)
>>> print(df_implicit_indexes)
    0   1   2
0  a0  b0  c0
1  a1  b1  c1
2  a2  b2  c2

Dans le cas des index numériques implicites loc[<index ligne>, <index colonne>] fonctionne:

>>> df_implicit_indexes.loc[1, 2]
'c1'

Si des index personnalisés existent, loc[] ne fonctionne pas avec les index numériques:

>>> df_custom_indexes[1, 2]
KeyError: (1, 2)

>>> df_custom_indexes.loc['l1', 'c']
'c1'

iloc[<index ligne>, <index colonne>] fonctionne dans tous les cas seulement pour des index numériques:

>>> df_implicit_indexes.iloc[1, 2]
'c1'

>>> df_custom_indexes.iloc[1, 2]
'c1'

Sous-ensembles

On peut extraire des sous-ensembles en utilisant des index sous la forme slicing:

[<index de début>:<index de fin exclu>:<pas utilisé>]

Ainsi comme pour les listes python classiques:

  • [2:] permet de commencer à l’index 2 (3e élément) jusqu’au dernier.
  • [:3] permet de commencer du début jusqu’à l’index 2 (3e élément). L’index (4e élément) est exclu.
  • [:] désigne tous les éléments de la liste.

Par exemple, si on considère le dataframe définit plus haut:

>>> print(df_implicit_indexes)
    0   1   2
0  a0  b0  c0
1  a1  b1  c1
2  a2  b2  c2

Alors:

  • df_implicit_indexes.iloc[1, 2] correspond à l’élément à l’index ligne 1 (2e ligne) et colonne 2 (3e colonne):
    'c1'
    
  • df_implicit_indexes.iloc[:, 2] correspond à une série contenant toutes les lignes et la colonne 2 (3e colonne):
    0    c0
    1    c1
    2    c2
    Name: 2, dtype: object
    
  • df_implicit_indexes.iloc[:, 0:2] correspond à un dataframe contenant toutes les lignes et les colonnes 0 à 2, la colonne 2 étant exclue:
        0   1
    0  a0  b0
    1  a1  b1
    2  a2  b2
    
  • df_implicit_indexes.iloc[:, 0:2:2] correspond à un dataframe contenant toutes les lignes et les colonnes 0 à 2 avec la colonne 2 exclue. Les colonnes seront énumérées par pas de 2:
        0
    0  a0
    1  a1
    2  a2
    
  • df_implicit_indexes.iloc[0:2, 2] correspond à une série contenant les lignes 0 à 2 avec 2 exclu et la colonne 2 (3e colonne):
    0    c0
    1    c1
    Name: 2, dtype: object
    
  • df_implicit_indexes.iloc[1, [0, 2]] correspond à une série contenant la ligne 1 (2e ligne) et les colonnes 0 (1ère colonne) et 2 (3e colonne):
    0    a1
    2    c1
    Name: 1, dtype: object
    

Avec loc[] et des index personnalisés, on peut aussi utiliser la forme slicing [:] ou [<index 1>, <index 2>, ..., <index i>], par exemple:

  • df_custom_indexes.loc[:,'c'] correspond à une série contenant toutes les lignes et la colonne 'c':
    l0    c0
    l1    c1
    l2    c2
    Name: c, dtype: object
    
  • df_custom_indexes.loc[['l0', 'l1'],:] correspond à un dataframe contenant les lignes 'l0' et 'l1' et toutes les colonnes:
         a   b   c
    l0  a0  b0  c0
    l1  a1  b1  c1
    

at[] et iat[]

at[] et iat[] permettent d’extraire des éléments d’un dataframe comme les colonnes respectivement loc[] et iloc[] c’est-à-dire on peut utiliser:

  • at[] pour les index personnalisés et
  • iat[] pour les index numériques. S’il n’existe pas d’index personnalisé alors at[] fonctionne avec des index implicites numériques.

La différence est qu’avec at[] et iat[], il n’est possible d’utiliser que des index simples sans les formes slicing [:] ou [<index 1>, <index 2>, ..., <index i>]. Si on prend les exemples de dataframes définis plus haut:

>>> print(df_implicit_indexes)
    0   1   2
0  a0  b0  c0
1  a1  b1  c1
2  a2  b2  c2

Alors:

  • df_implicit_indexes.iat[1, 2] renvoie l’élément se trouvant à la ligne 1 (2e ligne) et la colonne 2 (3e colonne):
    'c1'
    
  • df_implicit_indexes.iat[:, 2] renvoie une erreur:
    ValueError: iAt based indexing can only have integer indexers
    
  • df_implicit_indexes.iat[:, [0, 2]] renvoie une erreur:
    ValueError: iAt based indexing can only have integer indexers
    
>>> print(df_custom_indexes)
     a   b   c
l0  a0  b0  c0
l1  a1  b1  c1
l2  a2  b2  c2

Alors:

  • df_custom_indexes.at['l1', 'c'] renvoie l’élément à la ligne l1 et la colonne c:
    'c1'
    
  • df_custom_indexes.at[1, '2] renvoie une erreur car il existe des index personnalisés pour df_custom_indexes.
  • df_implicit_indexes.at[1, 2] ne renvoie pas d’erreur car il n’existe que des index numériques pour df_implicit_indexes:
    'c1'
    

Multi-index

Les multi-index permettent d’avoir des index et des index de colonnes sur plusieurs niveaux de façon à identifier des valeurs suivant un ensemble de clés. Pour accéder aux éléments du dataframe, au lieu d’utiliser un index à une valeur, on peut utiliser un agrégat de valeurs. Les multi-index peuvent être utilisés pour identifier des colonnes ou des lignes du dataframe. Plus précisemment, le terme “index” est utilisé pour désigner des labels pour les lignes toutefois le terme “multi-index” est applicable pour les lignes et les colonnes.

Par exemple, si on considère le dataframe suivant:

>>> data = [['a0', 'a1', 'a2', 'a3'], ['b0', 'b1', 'b2', 'b3'], ['c0', 'c1', 'c2', 'c3'], ['d0', 'd1', 'd2', 'd3']]
>>> df = pd.DataFrame(data)
>>> print(df)
    0   1   2   3
0  a0  a1  a2  a3
1  b0  b1  b2  b3
2  c0  c1  c2  c3
3  d0  d1  d2  d3

On utilise un index implicite pour les colonnes et les lignes:

>>> list(df.index)
[0, 1, 2, 3]

>>> list(df.columns)
[0, 1, 2, 3]

Si on utilise un index explicite pour les colonnes:

>>> df_index = ['a', 'b', 'c', 'd']
>>> df.columns = df_index
>>> print(df)
    a   b   c   d
0  a0  a1  a2  a3
1  b0  b1  b2  b3
2  c0  c1  c2  c3
3  d0  d1  d2  d3

On peut identifier les colonnes en utilisant une valeur parmi les valeurs de l’index explicite:

>>> df.loc[1, 'b']
'b1'

Si on considère le multi-index suivant à 2 niveaux:

>>> df_index_values = [['group1', 'group1', 'group2', 'group2'], ['a', 'b', 'c', 'd']]
>>> df_index = pd.MultiIndex.from_arrays(df_index_values)

On affecte le multi-index aux colonnes du dataframe:

>>> df.columns = df_index
>>> print(df)
  group1     group2    	
       a   b      c   d
0     a0  a1     a2  a3
1     b0  b1     b2  b3
2     c0  c1     c2  c3
3     d0  d1     d2  d3

Pour identifier une colonne, on doit utiliser un agrégat de valeurs:

>>> df.loc[1, ('group1', 'b')]
'b1'

On peut utiliser des multi-index sur n niveaux, par exemple sur 3 niveaux:

>>> df_index_values = [['A', 'B', 'B', 'C'], ['group1', 'group1', 'group2', 'group2'], ['a', 'b', 'c', 'd']]
>>> df_index = pd.MultiIndex.from_arrays(df_index_values)
>>> df.columns = df_index
>>> print(df)
       A      B             C
  group1 group1 group2 group2
       a      b      c      d
0     a0     a1     a2     a3
1     b0     b1     b2     b3
2     c0     c1     c2     c3
3     d0     d1     d2     d3

Accès aux éléments en utilisant un multi-index

On peut accéder aux éléments du dataframe en utilisant 1, 2 ou 3 niveaux:

>>> print(df.loc[:, 'B'])         # 1 niveau
  group1 group2
       b      c
0     a1     a2
1     b1     b2
2     c1     c2
3     d1     d2

>>> print(df.loc[:, ('B', 'group1')])         # 2 niveaux
    b
0  a1
1  b1
2  c1
3  d1

>>> df.loc[:, ('B', 'group1', 'b')]           # 3 niveaux
0    a1
1    b1
2    c1
3    d1
Name: (B, group1, b), dtype: object
On ne peut pas changer l’ordre des éléments dans le multi-index

Par exemple

>>> print(df.loc[:, ('B', 'group1')])         # OK
     b
 0  a1
 1  b1
 2  c1
 3  d1 
>>> df.loc[:, ('group1', 'B')]
KeyError: ('group1', 'B')

Utilisation de multi-index pour les lignes

On peut utiliser des multi-index pour les lignes:

>>> data = [['a0', 'a1', 'a2', 'a3'], ['b0', 'b1', 'b2', 'b3'], ['c0', 'c1', 'c2', 'c3'], ['d0', 'd1', 'd2', 'd3']]
>>> df = pd.DataFrame(data)
>>> df_index_values = [['A', 'A', 'B', 'B'], ['a', 'b', 'c', 'd']]
>>> df_index = pd.MultiIndex.from_arrays(df_index_values)
>>> df.index = df_index
>>> print(df)
      0   1   2   3
A a  a0  a1  a2  a3
  b  b0  b1  b2  b3
B c  c0  c1  c2  c3
  d  d0  d1  d2  d3

Création de multi-index à partir d’un tableau

Comme l’exemple précédent, on peut créer un multi-index à partir d’un tableau en indiquant directement les différents niveaux.

Par exemple pour 2 niveaux:

>>> df_index_values = [['A', 'A', 'B', 'B'], ['a', 'b', 'c', 'd']]
>>> df_index = pd.MultiIndex.from_arrays(df_index_values)

Si on affecte ce multi-index aux colonnes du dataframe d’origine:

>>> df.columns = df_index
>>> print(df)
    A       B    
    a   b   c   d
0  a0  a1  a2  a3
1  b0  b1  b2  b3
2  c0  c1  c2  c3
3  d0  d1  d2  d3

Création de multi-index à partir de tuples

La création de multi-index à partir de tuples est assez directe:

>>> df_index_values = [('A', 'a'), ('A', 'b'), ('B', 'c'), ('B', 'd')]
>>> df_index = pd.MultiIndex.from_tuples(df_index_values)

Si on affecte ce multi-index aux colonnes du dataframe d’origine, on obtient le même résultat que précédemment:

>>> df.columns = df_index
>>> print(df)
    A       B    
    a   b   c   d
0  a0  a1  a2  a3
1  b0  b1  b2  b3
2  c0  c1  c2  c3
3  d0  d1  d2  d3

Création de multi-index en effectuant un produit cartésien

Pour réduire la complexité de création d’un multi-index, il est possible d’effectuer un produit cartésien entre plusieurs valeurs pour obtenir les différents niveaux, par exemple:

>>> first_level = ['a', 'b']
>>> second_level = ['A', 'B']
>>> df_index = pd.MultiIndex.from_product([first_level, second_level])

Avec le dataframe d’origine, on obtient:

>>> df.columns = df_index
>>> print(df)
    a       b    
    A   B   A   B
0  a0  a1  a2  a3
1  b0  b1  b2  b3
2  c0  c1  c2  c3
3  d0  d1  d2  d3

Création de multi-index à partir d’un dataframe

Pour créer un dataframe à partir d’un dataframe

>>> df_multiindex = pd.DataFrame([['1', 'A'], ['1', 'B'], ['2', 'A'], ['2', 'B']])
>>> print(df_multiindex)
   0  1
0  1  A
1  1  B
2  2  A
3  2  B

>>> df_index = pd.MultiIndex.from_frame(df_multiindex)

Avec le dataframe d’origine, on obtient:

>>> df.columns = df_index
>>> print(df)
0   1       2    
1   A   B   A   B
0  a0  a1  a2  a3
1  b0  b1  b2  b3
2  c0  c1  c2  c3
3  d0  d1  d2  d3

Création de multi-index avec les arguments levels et codes

Les arguments levels et codes permettent d’indiquer de quelle façon les niveaux sont définis:

  • L’argument levels permet d’indiquer un tableau pour lequel chaque ligne contient les labels pour un niveau donné.
  • L’argument codes utilise un tableau pour lequel chaque ligne indique comment les labels sont utilisés par rapport aux colonnes du dataframe. Ces indications sont faites en indiquant un index de la ligne correspondante dans le tableau de l’argument levels.

Par exemple on souhaite définir un multi-index à 2 niveaux:

  • 1er niveau avec les labels '1' et '2'
  • 2e niveau avec les labels 'A' et 'B'.
    Le but étant d’avoir le multi-index suivant:

    0   1       2    
    1   A   B   A   B
    

L’argument levels permettant de définir les niveaux contiendra le tableau suivant: [['1', '2'], ['A, 'B']].
L’argument codes va indiquer la façon dont les labels sont utilisés suivant les colonnes du dataframe. Sachant que le dataframe contient 4 colonnes donc chaque ligne du tableau de codes contiendra 4 éléments. Ces éléments indiquent l’index de la ligne dans levels à utiliser pour une colonne du dataframe:

  • Pour le 1er niveau: [0, 0, 1, 1] car 0 est l’index de '1' et 1 est l’index de '2' dans ['1', '2'].
  • Pour le 2e niveau: [0, 1, 0, 1] car 0 est l’index de 'A' et 1 est l’index de 'B' dans ['A', 'B'].

On définit le multi-index de cette façon:

df_index = pd.MultiIndex(levels = [['1', '2'], ['A', 'B']],
  codes = [[0, 0, 1, 1], [0, 1, 0, 1]])

Avec le dataframe d’origine, on obtient:

>>> df.columns = df_index
>>> print(df)
0   1       2    
1   A   B   A   B
0  a0  a1  a2  a3
1  b0  b1  b2  b3
2  c0  c1  c2  c3
3  d0  d1  d2  d3

Nommer les niveaux du multi-index

Pour plus de clarté, il est possible de nommer les différents niveaux du multi-index. Il faut utiliser le paramètre names lors de la création du multi-index.

Par exemple pour un multi-index à 3 niveaux:

>>> df_multiindex = pd.DataFrame([['1', 'A', 'a'], ['1', 'B', 'b'], ['2', 'A', 'c'], ['2', 'B', 'd']])
>>> print(df_multiindex)
   0  1  2
0  1  A  a
1  1  B  b
2  2  A  c
3  2  B  d

>>> df_index = pd.MultiIndex.from_frame(df_multiindex, names=['level1', 'level2', 'level3'])

En affectant ce multi-index au dataframe d’origine, on peut effectuer le nommage des différents niveaux:

>>> df.columns = df_index
>>> print(df)
level1   1       2    
level2   A   B   A   B
level3   a   b   c   d
0       a0  a1  a2  a3
1       b0  b1  b2  b3
2       c0  c1  c2  c3
3       d0  d1  d2  d3

Modifier un dataframe

Un dataframe est un objet mutable. On peut modifier des éléments d’un dataframe en utilisant les mêmes opérateurs ou fonctions utilisés pour accéder à ces éléments. En effet, ces opérateurs et fonctions permettent de renvoyer une référence de l’élément dans le dataframe, on peut donc aussi les utiliser pour modifier la valeur de l’élément.

Ainsi les syntaxes suivantes permettent de modifier un élément dans un dataframe:

  • <dataframe>[<index colonne>][<index ligne>] = <nouvelle valeur>
  • <dataframe>.loc[<index ligne>, <index colonne>] = <nouvelle valeur>
  • <dataframe>.iloc[<index ligne numérique>, <index colonne numérique>] = <nouvelle valeur>
  • <dataframe>.at[<index ligne>, <index colonne>] = <nouvelle valeur>
  • <dataframe>.iat[<index ligne numérique>, <index colonne numérique>] = <nouvelle valeur>

Par exemple, si on considère le dataframe suivant:

>>> data = {'a': ['a0', 'a1', 'a2'], 'b': ['b0', 'b1', 'b2'], 'c': ['c0', 'c1', 'c2']}
>>> i = ['l0', 'l1', 'l2']
>>> df = pd.DataFrame(data, index=i)
>>> print(df)
     a   b   c
l0  a0  b0  c0
l1  a1  b1  c1
l2  a2  b2  c2

Les syntaxes suivantes permettent de modifier les éléments dans le dataframe:

>>> df['a']['l1'] = 'NEW - a1'
>>> df.loc['l1', 'b'] = 'NEW - b1'
>>> df.iloc[1, 2] = 'NEW - c1'
>>> df.at['l2', 'a'] = 'NEW - a2'
>>> df.iloc[2, 1] = 'NEW - b2'
>>> print(df)
           a         b         c
l0        a0        b0        c0
l1  NEW - a1  NEW - b1  NEW - c1
l2  NEW - a2  NEW - b2        c2

On peut modifier une colonne entière en affectant une série. Il faut que la série utilise le même index que le dataframe, par exemple si on considère le dataframe d’origine dans l’exemple précédent:

>>> df['b'] = pd.Series(['NEW/b0', 'NEW/b1', 'NEW/b2'], index=i)
>>> print(df)
     a       b   c
l0  a0  NEW/b0  c0
l1  a1  NEW/b1  c1
l2  a2  NEW/b2  c2

Modifier les colonnes ou lignes

Renommer des lignes ou colonnes

On peut affecter de nouveaux labels pour les lignes ou les colonnes en utilisant les propriétés <dataframe>.index pour les lignes et <dataframe>.columns pour les colonnes.

Par exemple:

>>> df.columns = ['A', 'B', 'C']
>>> df.index = ['1', '2', '3']
>>> print(df)
    A   B   C
1  a0  b0  c0
2  a1  b1  c1
3  a2  b2  c2

Pour renommer un ou plusieurs noms de lignes/colonnes sans tout modifier, on peut utiliser <dataframe>.rename() mais il ne faut pas oublier l’argument inplace=True car rename() renvoie un nouveau dataframe.

Par exemple:

>>> df.rename(columns = {'a': 'B'}, index = {'l1': 'n1'}, inplace=True)
>>> print(df)
     A   b   c
l0  a0  b0  c0
n1  a1  b1  c1
l2  a2  b2  c2

Ajouter une colonne

On peut ajouter une colonne en affectant une série dans un label de colonne qui n’existe pas. Il faut que l’index de la série soit le même que celui du dataframe.

Par exemple:

>>> print(df)
     a   b   c
l0  a0  b0  c0
l1  a1  b1  c1
l2  a2  b2  c2
>>> df['d'] = pd.Series(['d0', 'd1', 'd2'], index=['l0', 'l1', 'l2'])
>>> print(df)
     a   b   c   d
l0  a0  b0  c0  d0
l1  a1  b1  c1  d1
l2  a2  b2  c2  d2

Si on crée la nouvelle colonne de la façon suivante, toutes les valeurs de la colonne contiendront la même valeur:

>>> print(df)
     a   b   c
l0  a0  b0  c0
l1  a1  b1  c1
l2  a2  b2  c2
>>> df['d'] = 'NEW'
>>> print(df)
     a   b   c    d
l0  a0  b0  c0  NEW
l1  a1  b1  c1  NEW
l2  a2  b2  c2  NEW

<dataframe>.assign()

On peut utiliser une autre syntaxe avec assign() pour ajouter une nouvelle colonne. assign() renvoie un nouveau dataframe, il n’est pas possible d’utiliser un argument inplace.

Par exemple:

>>> print(df)
     a   b   c
l0  a0  b0  c0
l1  a1  b1  c1
l2  a2  b2  c2
>>> new_column = pd.Series(['NEW/b0', 'NEW/b1', 'NEW/b2'], index=i)
>>> new_df = df.assign(d = new_column)
>>> print(new_df)
     a   b   c       d
l0  a0  b0  c0  NEW/b0
l1  a1  b1  c1  NEW/b1
l2  a2  b2  c2  NEW/b2

Ajouter une colonne à un index donné

Avec <dataframe>.insert(), on peut ajouter une colonne à un index particulier dans un dataframe existant en utilisant la syntaxe:

<dataframe>.insert(<index>, <nom nouvelle colonne>, <nouveaux éléments>)

Par exemple:

>>> print(df)
     a   b   c
l0  a0  b0  c0
l1  a1  b1  c1
l2  a2  b2  c2
>>> df.insert(1, 'd', ['NEW/d0', 'NEW/d1', 'NEW/d2'])
>>> print(df)
     a       d   b   c
l0  a0  NEW/d0  b0  c0
l1  a1  NEW/d1  b1  c1
l2  a2  NEW/d2  b2  c2

Changer l’ordre des colonnes

Avec reindex(), on peut réordonner les colonnes d’un dataframe. La fonction génère un nouveau dataframe:

<dataframe>.reindex(columns=['<colonne 1>', '<colonne 2>', ..., '<colonne N>'])

On peut utiliser l’option copy=False pour éviter de générer un nouveau dataframe.

Par exemple:

>>> printf(df)
     a   b   c
l0  a0  b0  c0
l1  a1  b1  c1
l2  a2  b2  c2

>>> new_df = df.reindex(columns=['c', 'b', 'a'])
>>> print(new_df)
     c   b   a
l0  c0  b0  a0
l1  c1  b1  a1
l2  c2  b2  a2

Une autre méthode est d’utiliser cette syntaxe (le résultat est le même):

>>> new_df = df[['c', 'b', 'a']]

Supprimer des colonnes

Plusieurs syntaxes sont possibles pour supprimer une ou plusieurs colonnes. Pour supprimer une colonne dans un dataframe en modifiant l’instance directement:

del <dataframe>['<colonne i>']

Par exemple:

>>> printf(df)
     a   b   c
l0  a0  b0  c0
l1  a1  b1  c1
l2  a2  b2  c2

>>> del df['c']
>>> print(df)
     a   b
l0  a0  b0
l1  a1  b1
l2  a2  b2

Les syntaxes suivantes sont équivalentes et permettent de supprimer plusieurs colonnes en générant un nouveau dataframe. Le dataframe courant n’est pas modifié. Pour modifier le dataframe courant, il faut rajouter l’option inplace = True.

  • <dataframe>.drop(['<colonne x>', '<colonne y>', '<colonne z>'], axis = 1)
  • <dataframe>.drop(columns = ['<colonne x>', '<colonne y>', '<colonne z>'])

La fonction <dataframe>.pop('<colonne i>') permet de supprimer une colonne et de renvoyer la série correspondant à la colonne supprimée.

Par exemple:

>>> print(df)
     a   b   c
l0  a0  b0  c0
l1  a1  b1  c1
l2  a2  b2  c2

>>> deleted_column = df.pop('b')
>>> deleted_column
l0    b0
l1    b1
l2    b2
Name: b, dtype: object

Supprimer des lignes

Les syntaxes suivantes permettent de supprimer des lignes d’un dataframe en générant un nouveau dataframe. Le dataframe courant n’est pas modifié. Pour modifier le dataframe courant, il faut rajouter l’option inplace = True.

  • <dataframe>.drop(['<ligne x>', '<ligne y>', '<ligne z>'])
  • <dataframe>.drop(index = ['<ligne x>', '<ligne y>', '<ligne z>'])

Modification de valeurs d’un dataframe

De nombreuses fonctions existent pour modifier directement un dataframe sans avoir à exécuter une boucle pour parcourir toutes les valeurs du dataframe:

  • <dataframe>.astype(<nouveau type>): modifie le type de toutes les valeurs d’un dataframe en générant un nouveau dataframe. Le type est le même que celui indiqué lors de l’initialisation. On peut utiliser l’option copy=False pour modifier le dataframe courant au lieu de générer un nouveau dataframe.
  • <dataframe>.astype({'colonne x': <type 1>, 'colonne y': <type 2>, 'colonne z': <type 3>}): permet de modifier le type de plusieurs colonnes. Le type est le même que celui indiqué lors de l’initialisation. On peut utiliser l’option copy=False pour modifier le dataframe courant au lieu de générer un nouveau dataframe.
  • <dataframe>.replace(<valeur à remplacer>, <valeur remplaçante>): permet de remplacer toute occurence d’une valeur dans un dataframe par une autre. Cette fonction génère un nouveau dataframe, pour modifier le dataframe courant, il faut utiliser l’option: inplace=True.

    On peut utiliser des syntaxes différentes pour effectuer plusieurs remplacements en une fois:

    • <dataframe>.replace([<valeur à remplacer 1>, <valeur remplaçante 1>], [<valeur à remplacer 2>, <valeur remplaçante 2>], [<valeur à remplacer 3>, <valeur remplaçante 3>])
    • <dataframe>.replace({<valeur à remplacer 1>: <valeur remplaçante 1>, <valeur à remplacer 2>: <valeur remplaçante 2>, <valeur à remplacer 3>: <valeur remplaçante 3>)
  • <dataframe>.fillna(<valeur remplaçante>): remplacer tous les NaN par une valeur particulière. Cette fonction génère un nouveau dataframe, pour modifier le dataframe courant, il faut utiliser l’option: inplace=True.
  • <dataframe>.dropna(): supprime toutes les lignes contenant au moins une valeur NaN. Un nouveau dataframe est renvoyé, le dataframe courant n’est pas modifié. On peut utiliser les options suivantes:
    • how = 'all': une ligne est supprimée si toutes les valeurs de cette ligne sont NaN.
    • inplace=True pour modifier le dataframe courant.
    • axis = 1 pour supprimer les colonnes contenant des valeurs NaN plutôt que des lignes.
  • <dataframe>.apply(<lambda>): applique une lambda à toutes les valeurs d’un dataframe. Le dataframe courant n’est pas modifié, le type de la sortie dépend des options.

    Par exemple, on considère le dataframe suivant:

    >>> df = pd.DataFrame({'a': [2, 3, 4], 'b': [5, 6, 7], 'c': [8, 9, 10]}, 
    	index=['l0', 'l1', 'l2'])
    >>> print(df)
        a  b   c
    l0  2  5   8
    l1  3  6   9
    l2  4  7  10
    
    • Par défaut et sans option, la lambda est exécutée pour chaque valeur du dataframe d’origine:
      >>> print(df.apply(lambda x: x ** 2))
           a   b    c
      l0   4  25   64
      l1   9  36   81
      l2  16  49  100
      

      La taille et la structure du dataframe d’origine sont maintenues.

    • Avec l’option axis = 0: la lambda est appliquée sur chaque colonne. Le type de l’objet passé en paramètre de la lambda est une série. La sortie de la lambda n’est pas forcément dans la même dimension que l’entrée:
      >>> print(df.apply(lambda x: x['l0'] + x['l1'] + x['l2'], axis = 0))
      a     9
      b    18
      c    27
      dtype: int64
      

      La traitement se fait en colonne (par série). La sortie de la lambda est une valeur scalaire alors la sortie de df.apply() est une série.

      >>> print(df.apply(lambda x: [x['l0'], x['l1']], axis = 0))
         a  b  c
      0  2  5  8
      1  3  6  9
      

      La sortie de la lambda est un tableau avec 2 valeurs donc la sortie de df.apply() est un dataframe à 2 lignes. La dimension de l’objet en sortie dépend de la sortie de la lambda.

    • Avec l’option axis = 1: la lambda est appliquée sur chaque ligne. Le type de l’objet passé en paramètre de la lambda est une série. La sortie de la lambda n’est pas forcément dans la même dimension que l’entrée:
      >>> print(df.apply(lambda x: x['a'] + x['b'] + x['c'], axis = 1))
      l0    15
      l1    18
      l2    21
      dtype: int64
      

      La traitement se fait en ligne. La sortie de la lambda est une valeur scalaire alors la sortie de df.apply() est une série.

      >>> print(df.apply(lambda x: pd.Series([x['a'], x['b']]), axis = 1))
          0  1
      l0  2  5
      l1  3  6
      l2  4  7
      

      Si la sortie de la lambda est une série, alors la sortie de df.apply() sera un dataframe.

      Pour avoir des labels de colonnes particuliers il faut indiquer un index dans la série en sortie de la lambda:

      >>> print(df.apply(lambda x: pd.Series([x['a'], x['b']], index=['c0', 'c1']), axis = 1))
          c0  c1
      l0   2   5
      l1   3   6
      l2   4   7
      
      >>> print(df.apply(lambda x: [x['a'], x['b']], axis = 1))
      l0    [2, 5]
      l1    [3, 6]
      l2    [4, 7]
      dtype: object
      

      Si la sortie de la lambda est un tableau, alors la sortie de df.apply() est une série de tableaux.

    • result_type = 'expand' permet d’indiquer que la sortie de la lambda sera considérée comme des colonnes. Si on renvoie un tableau alors les éléments du tableau seront des colonnes:
      >>> print(df.apply(lambda x: [x['a'], x['b']], axis = 1, result_type='expand'))
          0  1
      l0  2  5
      l1  3  6
      l2  4  7
      

      Chaque élément du tableau de sortie de la lambda est considéré comme un élément de colonne du dataframe en sortie. Les labels des colonnes d’origine sont toutefois perdus.

    • result_type = 'reduce' permet d’indiquer que la sortie de la lambda sera considérée comme une réduction des colonnes du dataframe d’origine. Si la lambda renvoie un tableau, la sortie de df.apply() sera une série de tableau:
      >>> print(df.apply(lambda x: [x['a'], x['b']], axis = 1, result_type='reduce'))
      l0    [2, 5]
      l1    [3, 6]
      l2    [4, 7]
      dtype: object
      
    • result_type = 'broadcast' permet d’indiquer que la structure du dataframe d’origine sera préservée donc les labels des colonnes seront maintenus en sortie. Il n’est pas nécessaire que la lambda génère une série ou d’indiquer des index en sortie de la lambda:
      >>> print(df.apply(lambda x: [x['a'], x['b'], x['c']], axis = 1, result_type='broadcast'))
          a  b   c
      l0  2  5   8
      l1  3  6   9
      l2  4  7  10
      

      Les labels des colonnes sont maintenus dans le dataframe en sortie.

Modifier les valeurs d’un dataframe par condition

Il est possible de modifier plusieurs valeurs d’un dataframe en une seule ligne en indiquant des conditions.

Par exemple, si on considère le dataframe suivant:

>>> df = pd.DataFrame({'a': [2, 3, 4], 'b': [5, 6, 7], 'c': [8, 9, 10]}, 
	index=['l0', 'l1', 'l2'])
>>> print(df)
    a  b   c
l0  2  5   8
l1  3  6   9
l2  4  7  10

On peut modifier toutes les valeurs d’une colonne de cette façon:

>>> df['b'] = 0
>>> print(df)
    a  b   c
l0  2  0   8
l1  3  0   9
l2  4  0  10

On peut indiquer une condition valable pour toutes les valeurs du dataframe:

>>> print(df)
    a  b   c
l0  2  5   8
l1  3  6   9
l2  4  7  10

>>> df[df > 6] = 0
>>> print(df)
    a  b  c
l0  2  5  0
l1  3  6  0
l2  4  0  0

On affectera 0 à toutes les valeurs strictement supérieures à 6.

On peut aussi modifier une colonne suivant des conditions appliquées sur d’autres colonnes. Par exemple, si un élément de la colonne 'a' >= 3 alors on affecte 0 à l’élément de la même ligne dans la colonne 'b':

>>> print(df)
    a  b   c
l0  2  5   8
l1  3  6   9
l2  4  7  10

>>> df.loc[df['a'] >= 3,'b'] = 0
>>> print(df)
    a  b   c
l0  2  5   8
l1  3  0   9
l2  4  0  10

Modifier un dataframe en appliquant des fonctions

<dataframe>.drop_duplicates()

Cette fonction permet de supprimer les lignes redondantes dans un dataframe et de ne conserver qu’une seule occurence. Par défaut, la fonction renvoie un dataframe avec les duplicats supprimés. Cette fonction peut être utilisée avec les options suivantes:

  • inplace = True: permet de modifier le dataframe courant plutôt que de renvoyer un dataframe avec les modifications.
  • keep = False pour ne pas conserver au moins une ligne parmi les duplicats. keep = 'last' permet de ne conserver que la dernière occurence parmi les duplicats et keep = 'first' est la valeur par défaut, seule la 1ère occurence est conservée.
  • subset = [<nom des colonnes>]: permet d’indiquer quelles sont les colonnes à vérifier pour trouver les duplicats. Par défaut, toutes les colonnes sont vérifiées.

Par exemple, si on considère le dataframe suivant:

>>> df = pd.DataFrame([['1', 'A', 'a'], ['1', 'B', 'b'], ['1', 'B', 'c'], ['2', 'A', 'c'], ['2', 'B', 'd'], ['2', 'B', 'd']])
>>> print(df)
0  1  2
0  1  A  a
1  1  B  b
2  1  B  c
3  2  A  c
4  2  B  d
5  2  B  d

Par défaut, toutes les colonnes sont vérifiées et seule la 1ère occurence est conservée:

>>> print(df.drop_duplicates())
   0  1  2
0  1  A  a
1  1  B  b
2  1  B  c
3  2  A  c
4  2  B  d

Avec subset = [0], seule la colonne 0 est vérifiée pour trouver les duplicats:

>>> print(df.drop_duplicates(subset = [0]))
   0  1  2
0  1  A  a
3  2  A  c

Avec keep = 'last', les duplicats sont cherchés en vérifiant la colonne 0 et on ne garde que la dernière occurence.

>>> print(df.drop_duplicates(subset = [0], keep = 'last'))
   0  1  2
2  1  B  c
5  2  B  d

Tri des index et colonnes avec <dataframe>.sort_index()

<dataframe>.sort_index() permet de trier les éléments d’un dataframe suivant les index ou les colonnes. Par défaut le tri se fait dans le sens ascendant sur les index et en générant un nouveau dataframe. On peut utiliser les options suivantes:

  • inplace = True pour modifier le dataframe courant.
  • ascending = False pour trier par ordre décroissant.
  • axis=1 pour trier suivant les noms de colonnes.
  • level = <int> pour indiquer le niveau à trier dans le cas d’un index multi-niveau.

Par exemple:

>>> df = pd.DataFrame([['1', 'A', 'a'], ['1', 'B', 'b'], ['1', 'C', 'c']], 
                  columns = ['c', 'b', 'a'], 
                  index = ['z', 'y', 'x'])
>>> print(df)
   c  b  a
z  1  A  a
y  1  B  b
x  1  C  c

>>> print(df.sort_index())
   c  b  a
x  1  C  c
y  1  B  b
z  1  A  a

>>> print(df.sort_index(axis=1, ascending=True))
   a  b  c
z  a  A  1
y  b  B  1
x  c  C  1

Tri des valeurs avec <dataframe>.sort_values()

<dataframe>.sort_values() permet de trier les valeurs d’un dataframe. Il faut indiquer la colonne ou les colonnes à trier avec l’argument by. Par défaut le tri se fait dans le sens ascendant en générant un nouveau dataframe. On peut utiliser les options suivantes:

  • inplace = True pour modifier le dataframe courant.
  • ascending = False pour trier par ordre décroissant.
  • axis=1 pour trier suivant les noms de colonnes.
  • na_position indique où les valeurs NaN seront placées:
    • 'first': elles seront placées avant les valeurs triées
    • 'last': elles seront placées après les valeurs triées
  • key: permet d’indiquer une lambda qui sera utilisée pour appliquer un tri particulier sur les colonnes indiquées avec by. Le type de la valeur d’entrée de la lambda sera une série.

Par exemple:

>>> df = pd.DataFrame([['1', 'A', 'a', np.NaN], ['2', 'B', 'b', '0'], ['3', 'C', 'c', '1'], ['4', 'D', 'd', np.NaN]], 
                  columns = ['a', 'b', 'c', 'd'], 
                  index = ['w', 'x', 'y', 'z'])
>>> print(df)
   a  b  c    d
w  1  A  a  NaN
x  2  B  b    0
y  3  C  c    1
z  4  D  d  NaN

>>> print(df.sort_values(by = 'd', na_position='first'))
   a  b  c    d
w  1  A  a  NaN
z  4  D  d  NaN
x  2  B  b    0
y  3  C  c    1

On trie suivant la colonne 'd' et on place les valeurs NaN avant les valeurs triées.

>>> print(df.sort_values(by = ['d', 'c'], na_position='last'))
   a  b  c    d
0  2  B  b    0
1  3  C  c    1
2  1  A  a  NaN
3  4  D  d  NaN

On trie suivant 2 colonnes 'd' et 'c' et on place les valeurs NaN après les valeurs triées.

>>> print(df.sort_values(by = ['a'], key=lambda x: x.apply(lambda y: -int(y))))
   a  b  c    d
z  4  D  d  NaN
y  3  C  c    1
x  2  B  b    0
w  1  A  a  NaN

On effectue le tri suivant la colonne 'a' et on applique la lambda x: x.apply(lambda y: -int(y)) à la colonne 'a' avant d’effectuer le tri. x dans la lambda est une série. x.apply() permet d’appliquer une autre lambda aux valeurs de la série.

La série est entrée de la lambda est:

>>> df['a']
w    1
x    2
y    3
z    4
Name: a, dtype: object

Après application de la lambda:

>>> df['a'].apply(lambda y: -int(y))
w   -1
x   -2
y   -3
z   -4
Name: a, dtype: int64

Agrégation

Avec <dataframe>.groupby()

La fonction <dataframe>.groupby() permet de calculer des agrégats suivant une colonne, plusieurs colonnes ou suivant un mapping particulier. L’argument by permet d’indiquer les groupes des agrégats en indiquant:

  • Une colonne: <dataframe>.groupby([<nom colonne>])
  • Plusieurs colonnes: <dataframe>.groupby([<nom colonne 1>, <nom colonne 2>, ... , <nom colonne i>])
  • Une fonction de mapping: <dataframe>.groupby(<fonction effectuant le mapping>)

Il est possible d’affiner les critères du groupby en précisant certains arguments:

  • axis=1 pour trier suivant les noms de colonnes.
  • level = <int> pour indiquer le niveau à trier dans le cas d’un index multi-niveau.
  • sort=False pour ne pas trier les groupes.
  • group_keys=True: si on utilise apply() en sortie du groupby(), cet argument permet d’indiquer si on veut rajouter un index indiquant les clés de groupage.
  • dropna=False pour ne pas ignorer les valeurs NaN.

Le type de retour de la fonction <dataframe>.groupby() est pandas.core.groupby.generic.DataFrameGroupBy. Ce type autorise d’appliquer des méthodes lors de l’agrégation. Si la colonne ne permet pas l’agrégation, elle sera ignorée:

  • size() pour préciser le nombre de lignes par groupe.
  • sum() pour sommer les valeurs des groupes. Les colonnes où une somme n’est pas possible seront ignorées.
  • min() pour indiquer le minimum des valeurs groupées.
  • max() pour indiquer le maximum des valeurs groupées.
  • describe() pour générer des statistiques descriptives.
  • count() pour compter le nombre de groupes.
  • first() pour renvoyer la 1ère ligne de chaque groupe.
  • last() pour renvoyer la dernière ligne de chaque groupe.
  • nth() pour renvoyer la n-ième ligne de chaque groupe.
  • std() pour renvoyer l’écart-type (i.e. standard déviation) pour chaque groupe.
  • var() pour renvoyer la variance pour chaque groupe.
  • sem() pour renvoyer l’erreur type de la moyenne pour chaque groupe.
  • apply(<lambda>) pour effectuer un traitement sur les groupes d’agrégation.

Par exemple:

>>> df = pd.DataFrame([['1', 'A', 10, np.NaN], ['2', 'B', 15, 3], ['1', 'B', 5, 1], ['2', 'D', 35, np.NaN], ['2', 'A', 25, 3]], 
                  columns = ['a', 'b', 'c', 'd'])
>>> print(df)
   a  b   c    d
0  1  A  10  NaN
1  2  B  15  3.0
2  1  B   5  1.0
3  2  D  35  NaN
4  2  A  25  3.0

>>> df.groupby(['a'])
<pandas.core.groupby.generic.DataFrameGroupBy object at 0xffff58e920d0>

Le résultat d’une agrégation est de type DataFrameGroupBy sur lequel on peut appliquer les fonctions d’agrégation:

>>> print(df.groupby(['a']).sum())
    c    d
a         
1  15  1.0
2  75  6.0

La somme est effectuée sur les colonnes où c’est possible. Les autres colonnes sont ignorées.

>>> print(df.groupby(['a', 'b']).sum())
      c    d
a b         
1 A  10  0.0
  B   5  1.0
2 A  25  3.0
  B  15  3.0
  D  35  0.0

On peut effectuer l’agrégation sur plusieurs colonnes. Le dataframe résultant contient un index multi-niveau.

Si on applique l’agrégation suivant la colonne 'd', inclure ou non les valeurs NaN produit des résultats différents:

>>> print(df.groupby(['d']).sum())
      c
d      
1.0   5
3.0  40

>>> print(df.groupby(['d'], dropna=False).sum())
      c
d      
1.0   5
3.0  40
NaN  45

Si on effectue des agrégations en indiquant une fonction:

>>> def make_group(input_value):
    if input_value < 2:
        return 'group1'
    elif input_value == 2:
        return 'group2'
    else:
        return 'group3'
>>> print(df.groupby(make_group).sum())
         c    d
group1  25  3.0
group2   5  1.0
group3  60  3.0

Si on applique la fonction d’agrégation avec apply():

>>> df.groupby(['a']).apply(lambda x: x['b'])
a   
1  0    A
   2    B
2  1    B
   3    D
   4    A
Name: b, dtype: object

Le résultat est sous la forme d’une série avec un index multi-niveau. On peut éviter d’avoir un index correspondant à la clé d’agrégation avec l’option group_keys=False:

>>> df.groupby(['a'], group_keys=False).apply(lambda x: x['b'])
0    A
2    B
1    B
3    D
4    A
Name: b, dtype: object

Fonction d’agrégation aggregate() ou agg()

La fonction aggregate() ou agg() permet d’appliquer un traitement plus sophistiquée sur des agrégations. Elle s’applique sur un type pandas.core.groupby.generic.DataFrameGroupBy à la suite d’une fonction <dataframe>.groupby().

La fonction aggregate() ou agg() prend comme argument:

  • Une fonction, par exemple np.sum pour effectuer une somme des agrégats ou,
  • Une chaine de caractère indiquant le nom d’une fonction, par exemple 'sum' ou,
  • Une liste de fonctions à appliquer en indiquant directement la fonction ou en indiquant son nom, par exemple [np.mean, 'sum'] et,
  • Un dictionnaire permettant d’indiquer un nom de colonne et la fonction à appliquer et,
  • Des indications sur le nom de la colonne résultante, la colonne du dataframe où la fonction s’applique et la fonction à appliquer.

On peut utiliser les mêmes fonctions d’agrégation que celles indiquées plus haut:

  • np.size ou 'size' pour préciser le nombre de lignes par groupe.
  • np.sum ou 'sum' pour sommer les valeurs des groupes. Les colonnes où une somme n’est pas possible seront ignorées.
  • np.min ou 'min' pour indiquer le minimum des valeurs groupées.
  • np.max ou 'max' pour indiquer le maximum des valeurs groupées.
  • 'describe' pour générer des statistiques descriptives.
  • 'count' pour compter le nombre de groupes.
  • 'first' pour renvoyer la 1ère ligne de chaque groupe.
  • 'last' pour renvoyer la dernière ligne de chaque groupe.
  • np.std ou 'std' pour renvoyer l’écart-type (i.e. standard déviation) pour chaque groupe.
  • np.var ou 'var' pour renvoyer la variance pour chaque groupe.
  • 'sem' pour renvoyer l’erreur type de la moyenne pour chaque groupe.

Par exemple, si on considère le dataframe suivant:

>>> df = pd.DataFrame([['1', 'A', 10, np.NaN], ['2', 'B', 15, 3], ['1', 'B', 5, 1], ['2', 'D', 35, np.NaN], ['2', 'A', 25, 3]], 
                  columns = ['a', 'b', 'c', 'd'])
>>> print(df)
   a  b   c    d
0  1  A  10  NaN
1  2  B  15  3.0
2  1  B   5  1.0
3  2  D  35  NaN
4  2  A  25  3.0

On peut effectuer une agrégation sur une colonne et effectuer la somme du contenu des autres colonnes en indiquant directement la fonction à exécuter:

>>> print(df.groupby(['a']).agg(np.sum))
    c    d
a         
1  15  1.0
2  75  6.0

En indiquant le nom de la fonction:

>>> print(df.groupby(['a']).agg('sum'))
    c    d
a         
1  15  1.0
2  75  6.0

En indiquant plusieurs fonctions à exécuter dans une liste (les fonctions s’appliquent à toutes les colonnes):

>>> print(df.groupby(['a']).agg([np.mean, 'sum']))
      c        d     
   mean sum mean  sum
a                    
1   7.5  15  1.0  1.0
2  25.0  75  3.0  6.0

En utilisant un dictionnaire, on peut indiquer la colonne sur laquelle on souhaite appliquer une fonction:

>>> print(df.groupby(['a']).agg({'c': np.mean, 'd': 'sum'}))
      c    d
a           
1   7.5  1.0
2  25.0  6.0

On peut créer de nouvelles colonnes en indiquant le nom de la colonne résultante, la colonne du dataframe où la fonction s’applique et la fonction à appliquer:

>>> print(df.groupby(['a']).agg(moyenne_c = ('c', np.mean), sum_d = ('d', 'sum')))
   moyenne_c  sum_d
a                  
1        7.5    1.0
2       25.0    6.0

Fonctions mathématiques

Pour les fonctions suivantes, l’opération est appliquée, par défaut, pour chaque colonne. Le résultat est renvoyé sous la forme d’une série.
Pour que l’opération soit effectuée suivant les lignes, il faut utiliser l’argument axis = 1.

Quelques fonctions:

Fonction Explication
<dataframe>.mean() Moyenne des valeurs de chaque colonne.
<dataframe>.median() Moyenne médiane des valeurs de chaque colonne.
<dataframe>.min() Minimum pour chaque colonne.
<dataframe>.max() Maximum pour chaque colonne.
<dataframe>.std() Ecart-type pour chaque colonne.
<dataframe>.var() Variance pour chaque colonne.
<dataframe>.idxmax() Index du maximum de chaque colonne.
<dataframe>.idxmin() Index du minimim de chaque colonne.
<dataframe>.transpose() ou <dataframe>.T Transposée du dataframe.
<dataframe>.round(<nombre de décimales>) Arrondi de toutes les valeurs du dataframe.
<dataframe>.abs() Valeur absolue des valeurs numériques.

Opérations sur les dataframes

Lorsque qu’une opération parmi les opérations suivantes est appliquée sur des dataframes pandas, les opérations ne sont pas appliquées au sens mathématique comme s’il s’agissait de matrices: l’opération est appliquée sur chaque élément du dataframe. Dans le cas où 2 dataframes sont concernés, les calculs sont appliqués entre les éléments des dataframes situés à la même position.

Par exemple si on considère 2 dataframes:

>>> df1 = pd.DataFrame([[1, 2, 3, 4], [10, 20, 30, 40], [100, 200, 300, 400], [1000, 2000, 3000, 4000]])
>>> print(df1)
     0     1     2     3
0    1     2     3     4
1   10    20    30    40
2  100   200   300   400
3 1000  2000  3000  4000
     
>>> df2 = pd.DataFrame([[1, 2, 3, 4], [11, 21, 31, 41], [101, 201, 301, 401], [1001, 2001, 3001, 4001]])
>>> print(df2)
      0     1     2     3
0     1     2     3     4
1    10    20    30    40
2   100   200   300   400
3  1000  2000  3000  4000

Si on applique l’opération df1 * df2, on peut voir qu’il ne s’agit pas d’une multiplication de matrice:

>>> print(df1 * df2)
         0        1        2         3
0        1        4        9        16
1      110      420      930      1640
2    10100    40200    90300    160400
3  1001000  4002000  9003000  16004000

Les opérations suivantes sont applicables:

  • Les opérations arithmétiques classiques:
    • + ou <dataframe1>.add(<dataframe2>),
    • - ou <dataframe1>.sub(<dataframe2>),
    • / ou <dataframe1>.div(<dataframe2>),
    • * ou <dataframe1>.mul(<dataframe2>),
    • // pour la division entière et
    • % ou <dataframe1>.mod(<opérande>) pour le reste de la division entière.
  • <dataframe> ** 2 ou <dataframe>.pow(2) pour le carré
  • Les opérations booléennes en utilisant:
    • & pour “and”,
    • | pour “ou”,
    • ~ ou – pour “not”,
    • ^ pour le “ou exclusif”.
  • Les opérateurs de comparaison:
    • == ou <dataframe1>.eq(<dataframe2>) pour tester l’égalité,
    • != ou <dataframe1>.ne(<dataframe2>) pour tester l’inégalité,
    • < ou <dataframe1>.lt(<dataframe2>),
    • > ou <dataframe1>.gt(<dataframe2>),
    • <= ou <dataframe1>.le(<dataframe2>),
    • >= ou <dataframe1>.ge(<dataframe2>).
ne pas confondre eq() et equals()

eq() compare tous les éléments de 2 dataframes et renvoie un dataframe de même taille avec des booléens résultant de la comparaison de tous les éléments.
equals() compare tous les éléments des 2 dataframes et ne renvoient qu’un seul booléen qui est True si tous les éléments des dataframes sont égaux.

Si on prend les dataframes précédents:

>>> print(df1.eq(df2))
       0      1      2      3
0   True   True   True   True
1  False  False  False  False
2  False  False  False  False
3  False  False  False  False

>>> df1.equals(df2)
False

Opérations entre un dataframe et un autre type d’objet

Les opérations ne sont pas exclusivement réservées aux dataframes entre eux. On peut appliquer des opérations entre:

  • Un dataframe et un scalaire: l’opération avec le scalaire est appliquée sur tous les éléments.

    Par exemple:

    >>> df1 = pd.DataFrame([[1, 2, 3], [10, 20, 30], [100, 200, 300]], index = ['a', 'b', 'c'])
    >>> print(df1 * 3)
         0    1    2
    a    3    6    9
    b   30   60   90
    c  300  600  900
    
  • Un dataframe et une liste: la taille de la liste doit être de la même taille que la dimension du dataframe sur laquelle l’opération est appliquée. L’opération est appliquée sur toutes les lignes ou toutes les colonnes suivant le sens d’application de l’opération.

    Par exemple:

    >>> print(df1 + [5, 15, 25])
         0    1    2
    a    6   17   28
    b   15   35   55
    c  105  215  325
    

    Dans le cas précédent, l’addition des éléments de la liste est appliquée sur toutes les lignes.
    Le résultat est le même en exécutant df1.add([5, 15, 25], axis=1) ou df1.add([5, 15, 25], axis='columns').

    Dans le cas suivant, l’opération avec la liste est appliquée sur toutes les colonnes:

    >>> print(df1.add([5, 15, 25], axis=0)) 
         0    1    2
    a    6    7    8
    b   25   35   45
    c  125  225  325
    

    Une autre syntaxe équivalente est: df1.add([5, 15, 25], axis='index').

  • Un dataframe et une série pandas: l’opération est appliquée sur les labels communs. Si des labels ne sont pas communs, la valeur résultante est NaN.

    Par exemple:

    >>> s = pd.Series([5, 15, 25]
    >>> print(df1 + s)
         0    1    2
    a    6   17   28
    b   15   35   55
    c  105  215  325
    

    L’opération est appliquée dans le sens des lignes. Les labels utilisés sont les labels par défaut.

    >>> print(df1 + pd.Series([5, 15])
           0      1   2
    a    6.0   17.0 NaN
    b   15.0   35.0 NaN
    c  105.0  215.0 NaN
    

    La série ne possède pas le label 2 donc la colonne 2 n’est pas commune aux 2 objets et la valeur affectée est NaN.

    Dans l’exemple suivant, l’opération est appliquée en colonne:

    >>> print(df1.add(pd.Series([5, 15, 25], index=['a', 'b', 'c']), axis=0))
         0    1    2
    a    6    7    8
    b   25   35   45
    c  125  225  325
    

    Il faut indiquer les mêmes labels que le dataframe pour que l’opération soit possible.

  • Un dataframe et un dictionnaire: l’opération est appliquée pour les éléments du dictionnaire pour lesquels la clé correspond à un index du dataframe.

    Par exemple, dans le cas suivant l’opération est appliquée dans le sens des lignes:

    >>> d = {0: 5, 1: 15, 2: 25}
    >>> print(df1 + d)
         0    1    2
    a    6   17   28
    b   15   35   55
    c  105  215  325
    

    Dans le cas suivant, l’opération est appliquée dans le sens des colonnes.

    >>> d = {'a': 5, 'b': 15, 'c': 25}
    >>> print(df1.add(d, axis=0))
         0    1    2
    a    6    7    8
    b   25   35   45
    c  125  225  325
    

Argument fill_value

On peut utiliser l’argument fill_value pour indiquer la valeur à appliquer lorsque les index qui ne sont pas communs.

Par exemple:

>>> df1 = pd.DataFrame([[1, 2, 3], [10, 20, 30], [100, 200, 300]], index = ['a', 'b', 'c'])
>>> df2 = pd.DataFrame([[1, 2, 3], [11, 21, 31], [101, 201, 301]], index = ['a', 'b', 'd'])
>>> print(df1.add(df2))
      0     1     2
a   2.0   4.0   6.0
b  21.0  41.0  61.0
c   NaN   NaN   NaN
d   NaN   NaN   NaN

La valeur NaN est appliquée lorsque les index ne sont pas communs.

>>> print(df1.add(df2, fill_value=0))
       0      1      2
a    2.0    4.0    6.0
b   21.0   41.0   61.0
c  100.0  200.0  300.0
d  101.0  201.0  301.0

Les index 'c' et 'd' ne sont pas communs entre les 2 dataframes. La valeur 0 est utilisée lorsque les index ne sont pas communs.

Opérations avec des index ou des colonnes non communs

Les opérations sont appliquées si les labels des dataframes sont les mêmes. Dans les exemples suivants, les labels utilisés sont les labels par défaut. Si certains labels ne sont pas égaux alors le résultat est NaN pour les éléments ayant des labels différents.

Par exemple si on considère les dataframes suivants:

>>> df1 = pd.DataFrame([[1, 2, 3], [10, 20, 30], [100, 200, 300]], columns = ['a', 'b', 'c'])
>>> df2 = pd.DataFrame([[1, 2, 3], [11, 21, 31], [101, 201, 301]], columns = ['a', 'b', 'd'])
>>> print(df1 + df2)
     a    b   c   d
0    2    4 NaN NaN
1   21   41 NaN NaN
2  201  401 NaN NaN

Les colonnes non communes contiennent NaN.

Jointures

Il est possible d’utiliser la fonction merge() pour effectuer des jointures entre 2 dataframes ayant au moins une colonne en commun et obtenir un dataframe avec le résultat de cette jointure.

Par exemple:

>>> df1 = pd.DataFrame({'id': [2, 1, 5], 'value1': [100, 165, 628]}); 
>>> print(df1)
   id  value1
0   2     100
1   1     165
2   5     628

>>> df2 = pd.DataFrame({'id': [1, 2, 8, 10], 'value2': [92, 27, 87, 43]});
>>> print(df2)
   id  value2
0   1      92
1   2      27
2   8      87
3  10      43

>>> print(df1.merge(df2))
   id  value1  value2
0   2     100      27
1   1     165      92

La jointure est effectuée sur la colonne id puisque c’est la seule colonne commune. Les valeurs 1 et 2 dans cette colonne sont communes.
merge() peut être utilisé avec les options suivantes:

  • on: indique la colonne avec laquelle la jointure sera effectuée. Cette option est utile si plusieurs colonnes sont communes.

    Par exemple:

    >>> df1 = pd.DataFrame({'id': [2, 1, 5], 'id2': ['a', 'b', 'c'], 'value1': [100, 165, 628]}); 
    >>> print(df1)
       id id2  value1
    0   2   a     100
    1   1   b     165
    2   5   c     628
    
    >>> df2 = pd.DataFrame({'id': [1, 2, 8, 10], 'id2': ['c', 'e', 'd', 'a'], 'value2': [92, 27, 87, 43]});
    >>> print(df2)
       id id2  value2
    0   1   c      92
    1   2   e      27
    2   8   d      87
    3  10   a      43
    
    >>> print(df1.merge(df2, on='id2'))
       id_x id2  value1  id_y  value2
    0     2   a     100    10      43
    1     5   c     628     1      92
    
  • left_on et right_on: ces arguments permettent d’indiquer les colonnes sur lesquelles la jointure doit être effectuée respectivement pour le 1er (avant merge()) ou le 2e dataframe (fourni en argument de merge()).

    Par exemple, si on considère ces dataframes n’ayant pas de colonnes communes:

    >>> df1 = pd.DataFrame({'id_1': [2, 1, 5], 'value1': [100, 165, 628]}); 
    >>> print(df1)
       id_1  value1
    0     2     100
    1     1     165
    2     5     628
    
    >>> df2 = pd.DataFrame({'id_2': [1, 2, 8, 10], 'value2': [92, 27, 87, 43]});
    >>> print(df2)
       id_2  value2
    0     1      92
    1     2      27
    2     8      87
    3    10      43
    

    Si on effectue la jointure sur les colonnes id_1 et id_2:

    >>> print(df1.merge(df2, left_on='id_1', right_on='id_2'))
       id_1  value1  id_2  value2
    0     2     100     2      27
    1     1     165     1      92
    
  • left_index et right_index: ces arguments permettent d’effectuer une jointure sur les index plutôt que sur les colonnes. La jointure est effectuée pour le 1er dataframe (avant merge()) avec left_index et pour le 2e dataframe (fourni en argument de merge()) avec right_index.
  • how: cet argument permet d’imiter le comportement d’une jointure ouverte “outer join”, “left outer join” ou “right outer join”. Par défaut, la valeur est 'inner' pour jointure fermée. Les autres valeurs possibles:
    • 'outer' pour une jointure ouverte. Les entrées ayant des colonnes ou des index communs entre les 2 dataframes sont retournées. A ces résultats se rajoutent aussi les entrées qui ne sont pas communes entre les 2 dataframes.
    • 'left' pour une jointure ouverte par la gauche. Les entrées ayant des colonnes ou des index communs entre les 2 dataframes sont retournées. A ces résultats se rajoutent les entrées du 1er dataframe qui ne sont pas communs avec le 2e dataframe.
    • 'right' pour une jointure ouverte par la droite. Les entrées ayant des colonnes ou des index communs entre les 2 dataframes sont retournées. A ces résultats se rajoutent les entrées du 2e dataframe qui ne sont pas communs avec le 1er dataframe.
    • 'cross' pour effectuer un produit cartésien sur les entrées ayant des colonnes ou des index communs entre les 2 dataframes.
  • indicator: permet de rajouter une colonne nommée _merge permettant d’indiquer d’où provient l’entrée.

    Par exemple, si on considère les dataframes et la jointure suivants:

    >>> df1 = pd.DataFrame({'id': [2, 1, 5], 'value1': [100, 165, 628]}); 
    >>> df2 = pd.DataFrame({'id': [1, 2, 8, 10], 'value2': [92, 27, 87, 43]});
    >>> print(df1.merge(df2, how='outer', indicator=True))
       id  value1  value2      _merge
    0   2   100.0    27.0        both
    1   1   165.0    92.0        both
    2   5   628.0     NaN   left_only
    3   8     NaN    87.0  right_only
    4  10     NaN    43.0  right_only
    

Concaténation

On peut effectuer des concaténations de dataframes ou de séries avec la fonction pd.concat([<dataframes ou series>]).

Par exemple, si on considère les dataframes suivants:

>>> df1 = pd.DataFrame([[1, 2, 3], [10, 20, 30]])
>>> df2 = pd.DataFrame([[11, 21, 31], [100, 200, 300]]) 
>>> print(pd.concat([df1, df2]))
     0    1    2
0    1    2    3
1   10   20   30
0   11   21   31
1  100  200  300

On peut remarquer que les colonnes sont les mêmes entre les 2 dataframes (colonnes implicites). Les index sont réutilisés.
Dans le cas où des colonnes ne sont pas communes, les valeurs sont complétées avec des NaN:

>>> df1 = pd.DataFrame([[1, 2, 3], [10, 20, 30]], columns=['a', 'b', 'c'])
>>> df2 = pd.DataFrame([[11, 21, 31], [100, 200, 300]], columns=['a', 'b', 'd'])
>>> print(pd.concat([df1, df2]))
     a    b     c      d
0    1    2   3.0    NaN
1   10   20  30.0    NaN
0   11   21   NaN   31.0
1  100  200   NaN  300.0

On peut concaténer une série à un dataframe:

>>> s = pd.Series([100, 200])
>>> print(pd.concat([df1, s], axis=1))
    0   1   2    0
0   1   2   3  100
1  10  20  30  200

On peut utiliser les arguments suivants pour modifier le comportement. Pour la suite, on définit les dataframes suivants:

>>> df1 = pd.DataFrame([[1, 2, 3], [10, 20, 30]])
>>> print(df1)
    0   1   2
0   1   2   3
1  10  20  30

>>> df2 = pd.DataFrame([[11, 21, 31], [100, 200, 300]])
>>> print(df2)
     0    1    2
0   11   21   31
1  100  200  300
  • ignore_index: permet d’ignorer les index d’origine des dataframes pour utiliser des index implicites.
    Si on considère les dataframes suivants:

    >>> print(pd.concat([df1, df2], ignore_index=True))
         0    1    2
    0    1    2    3
    1   10   20   30
    2   11   21   31
    3  100  200  300
    
  • axis: permet d’indiquer l’axe sur lequel la concaténation est effectuée: 0 pour index (valeur par défaut) et 1 pour colonne.

    Par exemple:

    >>> print(pd.concat([df1, df2], axis=1))
        0   1   2    0    1    2
    0   1   2   3   11   21   31
    1  10  20  30  100  200  300
    

    Les labels de colonnes sont maintenus. On peut utiliser ignore_index pour recréer des labels de colonnes implicites:

    >>> print(pd.concat([df1, df2], axis=1, ignore_index=True))
        0   1   2    3    4    5
    0   1   2   3   11   21   31
    1  10  20  30  100  200  300
    
  • keys: permet de rajouter un niveau dans les index du dataframe de sortie correspondant permettant d’indiquer le dataframe de départ.
    >>> print(pd.concat([df1, df2], keys=['a', 'b']))
           0    1    2
    a 0    1    2    3
      1   10   20   30
    b 0   11   21   31
      1  100  200  300
    
  • join: permet d’indiquer le comportement lorsque les colonnes ou les index ne sont pas communs. Par défaut, la valeur est 'outer' pour indiquer les index ou les colonnes (suivant le sens de la concaténation) sont ajoutés dans le dataframe en sortie même s’ils ne sont pas communs.
    Si la valeur est 'inner' alors seuls les colonnes ou index communs seront pris en compte dans le dataframe en sortie.

    Par exemple, si on considère les dataframes suivants:

    >>> df3 = pd.DataFrame([[1, 2, 3], [10, 20, 30]], columns=['a', 'b', 'c'])
    >>> print(df3)
        a   b   c
    0   1   2   3
    1  10  20  30
    
    >>> df4 = pd.DataFrame([[11, 21, 31], [100, 200, 300]], columns=['a', 'b', 'd'])
    >>> print(df4)
         a    b    d
    0   11   21   31
    1  100  200  300
    

    On peut voir la différence de comportement avec l’option join='inner':

    >>> print(pd.concat([df3, df4]))
         a    b     c      d
    0    1    2   3.0    NaN
    1   10   20  30.0    NaN
    0   11   21   NaN   31.0
    1  100  200   NaN  300.0
    
    
    >>> print(pd.concat([df3, df4], join='inner'))
         a    b
    0    1    2
    1   10   20
    0   11   21
    1  100  200
    

Séries Pandas

Cet article fait partie d’une série d’articles sur la syntaxe de base Python.

Une série pandas est une liste mutable d’objets dont les index peuvent être personnalisés. Le type des objets n’est pas forcément le même.

Les séries pandas permettent de stocker tout type d’objets. L’intérêt de cette structure est l’utilisation d’index personnalisables permettant un accès performant aux objets. Les séries pandas ne permettent de stocker des objets que suivant une dimension. Pour stocker suivant 2 dimensions, il faut utiliser des dataframes.

Pandas peut être importé de cette façon pour utiliser les objets dans la bibliothèque:

import pandas as pd

Les séries pandas sont mutables c’est-à-dire qu’on peut modifier la valeur des éléments après instanciation.

Initialisation

On peut initialiser une série pandas à partir d’un tableau Python ou d’un tableau numpy.

Par exemple, à partir d’un tableau Python:

>>> a = pd.Series([1, 4, 5, 8])
>>> a
0    1
1    4
2    5
3    8
dtype: int64

On peut voir les valeurs ainsi que les index correspondant. Comme les index n’ont pas été précisés à l’initialisation, ce sont des index par défaut qui sont utilisés.

Pour initialiser une série pandas à partir d’un tableau numpy:

>>> a = np.array([1, 4, 5, 8])
>>> b = pd.Series(a)

Sans précision sur le type des éléments, pandas déduit le type des objets dans le cas où les objets ont le même type et sont de type float, int et bool sinon c’est le type object qui sera affecté:

>>> a = np.array(['1', '4', '5', '8'])
>>> a
0    1
1    4
2    5
3    8
dtype: object

De même si les types des éléments sont différents alors le type affecté sera object.

Indiquer explicitement le type des valeurs (argument dtype)

Pandas reconnait les types numpy donc la même syntaxe que numpy peut être utilisée, par exemple:

>>> a = pd.Series([1, 4, 5, 8], dtype='i8')
>>> a
0    1
1    4
2    5
3    8
dtype: int64

La syntaxe plus haut est équivalente à:

>>> a = pd.Series([1, 4, 5, 8], dtype=np.int8)

A condition d’avoir importé la bibliothèque numpy avec:

import numpy as np

Initialiser sans effectuer de copies (argument copy)

Par défaut, quand une série pandas est initialisée à partir d’un tableau Python ou numpy, une copie des éléments est effectuée. Il est possible d’effectuer une initialisation de la série en utilisant des références vers les objets de la structure d’origine avec l’argument copy:

>>> a = np.array([1, 4, 5, 8])
>>> b = pd.Series(a, copy=False)
>>> a
array([1, 4, 5, 8])
>>> b[2]=1000
>>> a
array([   1,    4, 1000,    8])

L’initialisation de la série pandas étant faite avec des références, si on modifie une valeur dans la série alors les éléments dans la structure d’origine sont aussi modifiés.

Indiquer explicitement des index (argument index)

Par défaut, les index des éléments sont des entiers à partir de 0. Avec l’argument index, on peut explicitement préciser des index. Le type indiqué de l’objet doit être un tableau de même taille que la liste des valeurs:

>>> i = range(4, 8)
>>> list(i)
[4, 5, 6, 7]
>>> a = pd.Series([1, 4, 5, 8], index=i)
>>> a
4    1
5    4
6    5
7    8
dtype: int64

Pour créer plus directement une série:

>>> a = pd.Series([1, 4, 5, 8], range(4, 8))

Si la série contient la même valeur:

>>> a = pd.Series(5, range(4))
>>> a
0    5
1    5
2    5
3    5
dtype: int64

On peut affecter un index particulier après initialisation avec la propriété <série>.index. Il faut que la taille du tableau de l’index soit la même que celle de la série.

Par exemple:

>>> a = pd.Series([1, 4, 5, 8])
>>> a.index = ['a', 'b', 'c', 'd']
>>> a
a    1
b    4
c    5
d    8
dtype: int64

Accéder à une valeur à partir de l’index

Pour atteindre une valeur particulière, il suffit d’utiliser l’index:

>>> a = pd.Series([1, 4, 5, 8])
>>> a[2]
5

Si l’index n’existe pas, une exception est levée:

>>> a[5]
KeyError: 5

Si on considère la série suivante:

>>> a = pd.Series([1, 4, 5, 8], ['a', 'b', 'c', 'd'])
>>> a
a    1
b    4
c    5
d    8
dtype: int64

Il existe d’autres méthodes pour obtenir une valeur dans une série:

  • <série>.at[<index>]: par exemple
    >>> a.at['c']
    5
    

    Une exception est levée si l’index n’existe pas.

  • <série>.loc[<index>]:
    >>> a.loc['c']
    5
    

    Une exception est levée si l’index n’existe pas.

  • <série>.get(<index>):
    >>> a.get('c')
    5
    

    Si l’index n’existe pas, None est renvoyé.

Ces 3 syntaxes sont équivalentes.

Même si un index personnalisé est utilisé (différent d’un entier à partir de 0), on peut accéder aux valeurs en utilisant l’index par défaut avec les syntaxes:

  • <série>[<index numérique>]:
    >>> a[1]
    4
    

    Une exception est levée si l’index n’existe pas.

  • <série>.iat[<index numérique>]:
    >>> a.iat[1]
    4
    

    Une exception est levée si l’index n’existe pas.

  • <série>.iloc[<index numérique>]:
    >>> a.iat[1]
    4
    

    Une exception est levée si l’index n’existe pas.

  • <série>.get(<index numérique>):
    >>> a.get(1)
    4
    

    Si l’index n’existe pas, None est renvoyé.

Les syntaxes <série>[<index numérique>] et <série>.get(<index numérique>) sont sources d’ambiguïtés car:

  • Si l’index existe alors elles renvoient la valeur correspondant à l’index sinon
  • Si l’index n’existe pas, elles renvoient la valeur correspondant à l’index numérique.

Si on utilise ces syntaxes, il faut donc s’assurer du type d’index qu’on manipule.

Les autres syntaxes ne sont pas concernées par ces problèmes d’ambiguïté.

Par exemple, si on considère les séries suivantes:

>>> i1 = list(range(3, -1, -1))
>>> a = pd.Series([1, 4, 5, 8], index=i1)
>>> a
3    1
2    4
1    5
0    8
dtype: int64
>>> b = pd.Series([1, 4, 5, 8], ['a', 'b', 'c', 'd'])
>>> b
a    1
b    4
c    5
d    8
dtype: int64

>>> a[0]
8 

0 existe en tant qu’index, la valeur renvoyée est la dernière valeur de la série.

>>> b[0]
1

0 n’existe pas en tant qu’index donc la valeur renvoyée correspond à l’index numérique 0.

Sous-série et slicing

On peut extraire des sous-séries à partir d’une série existante en indiquant explicitement les index numériques à extraire ou en utilisant la syntaxe de slicing.

Par exemple, si on considère la série suivante:

>>> a = pd.Series([1, 4, 5, 8], ['a', 'b', 'c', 'd'])
>>> a
a    1
b    4
c    5
d    8
dtype: int64

Pour extraire une sous-série en indiquant explicitement les index numériques à extraire:

>>> b = a[['b', 'd', 'c']]
>>> b 
b    4
d    8
c    5
dtype: int64

On peut utiliser les règles de slicing habituelles en utilisant les index numériques, par exemple:

>>> c =  a[1:3]
>>> c
b    4
c    5
dtype: int64

Enfin, iloc[] peut être utilisé pour extraire la sous-série en utilisant les index numériques. iloc[] évite les ambiguïtés décrites plus haut puisqu’il ne traite que les index numériques:

>>> a.iloc[1:3]
b    4
c    5
dtype: int64
>>> a.iloc[[1,3]]
b    4
d    8
dtype: int64

Suivant la façon dont la sous-série est extraite, il peut s’agir d’une copie ou d’une référence vers la série d’origine. Dans le cas de références, les modifications dans la sous-série entraînent des modifications dans la série d’origine:

  • Si on indique explicitement les index de la série, la sous-série est une copie.
  • Si on utilise la syntaxe de slicing sur les index numériques, la sous-série contient des références.

Par exemple, si on considère la série suivante:

>>> a = pd.Series([1, 4, 5, 8], ['a', 'b', 'c', 'd'])
>>> a
a    1
b    4
c    5
d    8
dtype: int64

Si on extrait une sous-série en indiquant explicitement les index de la série d’origine:

>>> b = a[['a', 'b']]
>>> b[0] = 1000
>>> b
a    1000
b       4
dtype: int64

>>> a
a    1
b    4
c    5
d    8
dtype: int64

La série d’origine n’est pas modifiée.

Si on effectue un slicing avec les index numériques:

>>> c = a[1:3] 
>>> c[0] = 1000
>>> c
b    1000
c       5
dtype: int64
>>> a
a       1
b    1000
c       5
d       8
dtype: int64

La série d’origine est modifiée.

Pour éviter de modifier la structure d’origine, on peut effectuer une copie avec copy():

c = a[1:3].copy()

Changement de type

Il est possible de changer le type des éléments d’une série en appliquant la fonction <série>.astype(<nouveau type>). La série résultante contient les mêmes index que la série d’origine. Pour indiquer le type, on peut utiliser la même syntaxe qu’à l’initialisation.

Par exemple:

>>> a = pd.Series(['5', '4', '3', '2', '1']) 
>>> a.astype(float)
0    5.0
1    4.0
2    3.0
3    2.0
4    1.0
dtype: float64

Par défaut, si le changement de type n’est pas possible, une erreur est renvoyée:

>>> a = pd.Series(['5', '4', '3', np.NaN, 'Oups', '1'])
>>> a.astype(float)
ValueError: could not convert string to float: 'Oups'

Cette erreur peut être ignorée en faisant:

>>> a.astype(float, errors='ignore')
0       5
1       4
2       3
3     NaN
4    Oups
5       1
dtype: object

En cas d’erreur, l’objet original est renvoyé.

Tester l’existence d’un index et d’une valeur

Tester l’existence d’un index

L’opérateur in peut être utilisé pour tester l’existence d’un index dans une série pandas, par exemple:

>>> a = pd.Series([1, 4, 5, 8], ['a', 'b', 'c', 'd'])
>>> 'b' in a
True
>>> 'e' in a 
False

Si on utilise in directement sur une série, on teste l’existence d’un index dans la série et non l’appartenance de la valeur aux valeurs de la série.

Tester l’existence d’une valeur

Pour tester l’appartenance d’une valeur aux valeurs de la série, il faut utiliser in avec <série>.values:

>>> 4 in a.values
True
>>> 7 in a.values
False

Itération sur les éléments de la structure

On peut itérer directement parmi les valeurs d’une série avec une boucle “for“:

>>> a = pd.Series([1, 4, 5, 8], ['a', 'b', 'c', 'd'])
>>> for item in a:
    print(item)
1
4
5
8

La fonction <série>.iteritems() peut être utilisée pour obtenir un itérable contenant pour chaque élément son index et sa valeur, par exemple:

>>> a = pd.Series([1, 4, 5, 8], ['a', 'b', 'c', 'd'])
>>> for item in a.iteritems():
    print('Index: %s - valeur: %d' % item)
Index: a - valeur: 1
Index: b - valeur: 4
Index: c - valeur: 5
Index: d - valeur: 8

Opérations sur les séries pandas

Des opérations mathématiques peuvent être appliquées directement sur des séries pandas.

Par exemple, si on considère la série:

>>> a = pd.Series([1, 4, 5, 8])
>>> a
0    1
1    4
2    5
3    8
dtype: int64
>>> 2*a
0     2
1     8
2    10
3    16
dtype: int64

On peut aussi appliquer des opérations entre 2 séries pandas mais contrairement aux tableaux numpy, il n’est pas obligatoire que les 2 séries soient de même dimension. Toutefois, il faut que les types des éléments des séries permettent l’application de l’opération.

Par exemple si on considère les séries suivantes:

>>> a = pd.Series([1, 4, 5, 8])
>>> b = pd.Series([5, 4, 3, 2])
>>> a+b
0     6
1     8
2     8
3    10
dtype: int64

L’opération est appliquée sur tous les éléments des séries en préservant le type de ces derniers.

Si le type n’est pas le même, il peut être modifié pour rendre l’opération possible, par exemple:

>>> a = pd.Series([1, 4, 5, 8])
>>> b = pd.Series([5.0, 4.0, 3.0, 2.0])
>>> a+b
0    5.0
1    4.0
2    3.0
3    2.0
dtype: float64

a est une série contenant des entiers et b contient des flottants, en appliquant l’opération les éléments de a sont transformés en flottants pour rendre l’opération possible. Le résultat est une série de flottants.

La modification du type n’est pas tout le temps possible, par exemple si on considère une série de chaînes de caractères:

>>> c = pd.Series(['5', '4', '3', '2'])
>>> a+c
TypeError: unsupported operand type(s) for +: 'int' and 'str'

En revanche, si les éléments de 2 séries sont des chaînes de caractères alors l’opération est possible, le résultat est la concaténation des chaînes:

>>> d = pd.Series(['1', '4', '5', '8'])
>>> c+d
0    51
1    44
2    35
3    28
dtype: object

Quand on applique l’opération *, tous les éléments des séries sont multipliés:

>>> a = pd.Series([1, 4, 5, 8])
>>> b = pd.Series([5, 4, 3, 2])
>>> a*b
0     5
1    16
2    15
3    16
dtype: int64

Si les tailles des séries ne sont pas les mêmes

Si les tailles des séries ne sont pas identiques, l’opération est quand même appliquée toutefois quand il n’existe pas d’éléments dans une série permettant l’opération, la valeur résultante est NaN:

>>> a = pd.Series([1, 4, 5, 8])
>>> b = pd.Series([5, 4, 3, 2, 1])
>>> a+b
0     6.0
1     8.0
2     8.0
3    10.0
4     NaN
dtype: float64

Dans ce cas, la 5e valeur est NaN et les valeurs sont transformées en flottants à cause de la valeur manquante dans a.

Si les index ne sont pas les mêmes

Dans le cas où les index des séries ne sont pas les mêmes:

  • Pour les index communs: l’opération est appliquée.
  • Pour les index qui ne sont pas communs: le résultat de l’opération est NaN.

Par exemple:

>>> a = pd.Series([1, 4, 5, 8], ['a', 'b', 'c', 'd'])
>>> b = pd.Series([5, 4, 3, 2], ['a', 'e', 'c', 'f'])
>>> a+b
a    6.0
b    NaN
c    8.0
d    NaN
e    NaN
f    NaN
dtype: float64

La fonction dropna() peut être utilisée pour supprimer les valeurs NaN:

>>> c = a+b
>>> c.dropna()
a    6.0
c    8.0
dtype: float64

Pour résumer, on peut appliquer les opérations comme:

  • +, -, / ou *. Ces opérations sont appliquées sur les éléments des tableaux avec le même index. Pour les éléments dont les index ne sont pas les mêmes, le résultat est NaN. On peut s’aider de dropna() pour supprimer les éléments dont la valeur est NaN.
  • Les opérations booléennes entre séries pandas peuvent être effectuées en utilisant:
    • & pour “and“,
    • | pour “ou“,
    • ~ pour “not“,
    • ^ pour le “ou exclusif“.
  • Appliquer des opérateurs de comparaison comme ==, <, >, <=, >= et !=.

Fonctions particulières

Quelques fonctions utiles

On peut appliquer les fonctions mathématiques numpy à une série pandas. Le résultat est une série.

Par exemple si on importe numpy avec import numpy as np:

>>> a = pd.Series([1, 4, 5, 8])
>>> np.log(a)
0    0.000000
1    1.386294
2    1.609438
3    2.079442
dtype: float64

De la même façon, les fonctions suivants peuvent être appliquées:

  • np.add(a, b); np.subtract(a, b); np.divide(a, b) ou np.multiply(a, b) pour respectivement ajouter, soustraire, diviser ou multiplier les éléments de séries pandas.
  • np.sum(a) ou np.prod(a) pour respectivement ajouter ou multiplier tous les éléments d’une série.
  • np.floor(a), np.ceil(a) ou np.trunc(a) pour effectuer des arrondis ou troncatures sur les éléments d’une série.
  • np.amin(a), np.amax(a) pour obtenir le minimum ou maximum parmi les éléments de la série.
  • np.argmin(a), np.argmax(a) pour obtenir l’index du minimum ou du maximum des éléments de la série.
  • np.mean() pour obtenir la moyenne des éléments de la série.

Une liste plus exhaustive des opérations numpy possibles peut être retrouvée sur: numpy.org/doc/stable/reference/routines.math.html.

D’autres fonctions permettent d’éviter d’itérer sur les éléments de la série:

  • <série>.index permet d’obtenir un itérable (de type RangeIndex ou Index) contenant les index de la série.
  • <série>.values pour obtenir un tableau numpy contenant les valeurs de la série.
  • <série>.unique pour obtenir un tableau numpy contenant les valeurs uniques de la série.
  • <série>.value_counts() permet d’obtenir une série avec les mêmes index que la série d’origine et le nombre d’occurence pour chaque valeur.
  • <série>.isna() ou <série>.isnull() renvoie une série avec les mêmes index que la série d’origine et des booléens pour indiquer si les valeurs correspondantes sont NaN.
  • <série>.inotna() ou <série>.notnull() renvoie une série avec les mêmes index que la série d’origine et des booléens pour indiquer si les valeurs correspondantes ne sont pas égales à NaN.
  • pd.isnull(<série>) renvoie une série dont les index sont les mêmes que la série d’origine et dont les valeurs sont True si les valeurs correspondantes sont égales à NaN ou None.
  • pd.notnull(<série>) renvoie une série dont les index sont les mêmes que la série d’origine et dont les valeurs sont True si les valeurs correspondantes ne sont pas égales à NaN ou None.
  • <série>.min(), <série>.max(), <série>.mean(), <série>.median() pour respectivement renvoyer le minimum, maximum, la moyenne et la moyenne médiane des valeurs de la série.
  • <série>.all() indique si toutes les valeurs de la série sont égales à True au sens Truthy/Falsy (voir Truthy vs Falsy).
  • <série>.any() indique si au moins une valeur de la série est égale à True au sens Truthy/Falsy.
  • <série>.sort_index() renvoie une série avec les index ordonnés.
  • <série>.sort_values() renvoie une série avec les valeurs ordonnées.
  • <série>.apply(<fonction>) renvoie une série où la fonction est exécutée pour toutes les valeurs.

    Par exemple avec une lambda:

    >>> a = pd.Series([1, 4, 5, 8])
    >>> a.apply(lambda x: 3 * x)
    a     3
    b    12
    c    15
    d    24
    dtype: int64
    
  • <série>.to_frame() renvoie un dataframe avec une seule colonne contenant les valeurs de la série en ligne.

<série>.str

L’objet <série>.str permet d’appliquer des traitements sur les éléments d’une série lorsque ce sont des chaînes de caractères.

Par exemple:

  • <série>.str.startswith(<chaine de caractères>): renvoie une série dont les index sont les mêmes que la série d’origine et dont les valeurs contiennent True si la chaine de caractères correspondante commence par la chaîne donnée.
  • <série>.str.len() renvoie une série dont les index sont les mêmes que la série d’origine et dont les valeurs sont les longueurs des chaînes de caractères correspondantes.
  • <série>.str.match(<regex>) renvoie une série dont les valeurs sont True si la valeur correspondante dans la série d’origine satisfait la regex donnée.
  • <série>.str.contains(<regex>) renvoie une série dont les valeurs sont True si la valeur correspondante dans la série d’origine contient une sous-chaine satisfaisant la regex donnée.
  • <série>.str.contains(<chaîne de caractères>, regex=False) renvoie une série dont les valeurs sont True si la valeur correspondante dans la série d’origine contient une sous-chaine donnée.
  • <série>.str.find(<chaine>) renvoie une série dont les valeurs sont les index dans la chaîne de caractère de la 1ère occurence de la chaine donnée. Si la chaîne ne contient pas la chaine donnée, la valeur retournée est -1.
  • <série>.str.get(<index>) renvoie une série dont les valeurs contiennent le caractère correspondant à l’index donnée dans la chaîne de caractères correspondante.
  • <série>.str.slice(<index début>, <nombre de caractères>) renvoie une série dont les valeurs sont des sous-chaînes de la chaine correspondante dans la série d’origine.
  • <série>.str[<argument slicing>] renvoie une série dont les valeurs proviennent d’un slicing appliquée sur la chaîne correspondante dans la série d’origine.
  • <série>.str.count(<regex>) renvoie une série dont les valeurs contiennent le nombre d’ocurrences de la regex dans la chaîne correspondante dans la série d’origine.
  • <série>.str.replace(<regex>, <chaine de remplacement>) renvoie une série dont les valeurs contiennent un remplacement des chaines d’origine suivant la regex.

Tableaux Numpy

Cet article fait partie d’une série d’articles sur la syntaxe de base Python.

On se propose de passer en revue les fonctionnalités principales de quelques structures permettant des stocker des éléments en Python. On commence pour les tableaux numpy, d’autres articles permettront d’étudier les séries et les dataframes pandas.

Par rapport aux listes basiques, les tableaux numpy imposent que tous les objets soient de même type. Cette condition sécurise davantage le contenu du tableau par rapport aux listes Python. Les tableaux numpy sont mutables et ordonnés.
Les tableaux numpy permettent de stocker tout type d’objets toutefois ils sont particulièrement adaptés pour les valeurs numériques. En effet, les tableaux numpy offrent une solution pour stocker des matrices de plusieurs dimensions et pour effectuer des opérations entre matrices.

On peut importer numpy de cette façon pour utiliser les objets dans la bibliothèque:

import numpy as np

Voir Import de modules pour plus de détails.

Initialisation

Classiquement, un tableau numpy s’initialise en utilisant une liste Python:

>>> a = np.array([1, 4, 5, 8])

Sans précision sur le type des éléments, numpy déduit le type des objets en sélectionnant un type le plus précis qui permet de prendre en compte toutes les valeurs. Une conversion implicite est effectuée pour que toutes les valeurs soient du même type.

Par exemple, le type des objets du tableau plus haut sera:

>>> a.dtype 
dtype('int64')

Si une valeur est de type float avec tous les éléments du tableau en float:

>>> a = np.array([1, 4, 5., 8])
>>> a.dtype
dtype('float64')

Si un élément est une chaine de caractères alors le type du tableau sera une chaine de caractères car c’est le type le plus précis qui permet de stocker toutes les valeurs. Les autres valeurs seront implicitement convertis:

>>> a = np.array([1, 4, 5., '8'])
>>> a.dtype
dtype('<U32')

>>> a
array(['1', '4', '5.0', '8'], dtype='<U32')

'<U32' correspond au type d’une chaîne de caractères:

  • < pour indiquer l’ordre des octets (< pour little endian et > pour big endian).
  • U pour signifier une chaîne de caractères Unicode.
  • 32 pour indiquer 32 octets.

Si on indique un type ne pouvant contenir les données, les données seront tronquées:

names = ['a1', 'b2', 'c3', 'd4', 'e5', 'f6', 'g7', 'h8', 'i9', 'j0']
np_array = np.array(names, 'U1')
array(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j'], dtype='<U1')

Si les données ne peuvent pas être converties, il peut survenir une erreur:

names = ['a1', 'b2', 'c3', 'd4', 'e5', 'f6', 'g7', 'h8', 'i9', 'j0']
np_array = np.array(names, float)
ValueError could not convert string to float: 'a1'

Préciser le type des éléments

On peut explicitement préciser le type des éléments:

>>> a = np.array([1, 4, 5, 8], float)
>>> a
array([1., 4., 5., 8.])

Si une valeur n’est pas convertible implicitement alors une erreur est générée:

>>> a = np.array([1, 4, 5, 8, 'chaine'], float)
ValueError: could not convert string to float: 'chaine'

Les types possibles des éléments sont: numpy.org/doc/stable/reference/arrays.scalars.html.

Quelques types courants:

  • Nombre flottant: float ou np.float64
  • Nombre entier: int ou int64
  • Booléen: bool
  • Chaine de caractères: str

On peut utiliser les raccourcis suivants pour préciser les types:

  • '?': booléen
  • 'b': (signé) octet
  • 'B': octet non signé
  • 'i': (signé) entier
  • 'u': entier non signé
  • 'f': flottant
  • 'c': flottant complexe
  • 'm': timedelta
  • 'M': datetime
  • 'O': (Python) objects
  • 'U': chaîne de caractères unicode
  • 'V': Donnée brute (void)

Pour les types de nombre, on peut préciser le nombre d’octets occupé par l’objet, par exemple:

  • 'i4' désigne en entier sur 4 octets soit sur 32 bits.
  • 'i8' désigne en entier sur 8 octets soit sur 64 bits.

Pour plus de détails, voir numpy.org/doc/stable/reference/arrays.dtypes.html.

Créer un tableau à plusieurs dimensions

On peut initialiser un tableau numpy avec des tableaux à plusieurs dimensions, par exemple:

  • 1 dimension:
    >>> oneDimArray=[1,2,3]
    >>> a=np.array(oneDimArray)
    >>> a.shape
    (3, )
    
  • 2 dimensions:
    >>> twoDimArray=[[1,2,3],[4,5,6],[7,8,9]]
    >>> a=np.array(twoDimArray)
    >>> a.shape
    (3, 3)
    
  • 3 dimensions:
    >>> threeDimArray=[
        [[1,2,3],[4,5,6],[7,8,9]],
        [[10,11,12],[13,14,15],[16,17,18]],
        [[19,20,21],[22,23,24],[25,26,27]]
    ]
    >>> a=np.array(threeDimArray)
    >>> a.shape
    (3, 3, 3)
    

Il n’y a pas de limitation concernant le nombre de dimensions.

Construction avec des fonctions particulières

Quelques fonctions permettent de construire un tableau numpy avec des caractéristiques particulières.

arange()

La fonction arange() est l’équivalent de range() pour les listes Python. Cette fonction permet de créer un tableau:

  • np.arange(10) va créer un tableau de 10 éléments en commençant par 0.
    >>> np.arange(10)
    array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
    
  • np.arange(5, 10) crée un tableau d’entiers de 5 à 10 (10 exclu):
    >>> np.arange(5, 10) 
    array([5, 6, 7, 8, 9])
    
  • np.arange(5, 10, 2) crée un tableau d’entiers de 5 à 10 par pas de 2 (10 est exclu):
    >>> np.arange(5, 10, 2) 
    array([5, 7, 9])
    
  • On peut préciser un type particulier: np.arange(5, 10, 2, dtype=np.float64) produit un tableau de flottants de 5 à 10 par pas de 2 (10 exclu):
    >>> np.arange(5, 10, 2, dtype=np.float64) 
    array([5., 7., 9.])
    

linspace()

Cette fonction a un comportement similaire à arange() à la différence qu’elle n’exclut pas le dernier élément.

Ainsi:

  • np.linspace(1, 10, 5) permet de créer un tableau de 5 flottants de 1 à 10 inclus:
    >>> np.linspace(1, 10, 5)
    array([ 1.  ,  3.25,  5.5 ,  7.75, 10.  ])
    
  • np.linspace(1, 10, 5, dtype=int) permet de créer un tableau de 5 entiers de 1 à 10 inclus:
    >>> np.linspace(1, 10, 5, dtype=int)
    array([ 1,  3,  5,  7, 10])
    

reshape()

reshape() permet de créer un tableau à partir d’un autre tableau en redimensionnant sa taille. La seule condition entre le tableau de départ et d’arrivée est d’avoir le même nombre d’éléments.

Par exemple, si on considère le tableau:

>>> a=np.array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
>>> np.reshape(a, (2, 5)) 
array([[0, 1, 2, 3, 4],
       [5, 6, 7, 8, 9]])
np.reshape(a, (2, 5)) permet de créer un tableau de 2 lignes et 5 colonnes. 

De même:

>>> a=np.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]])
>>> np.reshape(a, 9)
array([0, 1, 2, 3, 4, 5, 6, 7, 8])

np.reshape(a, 9) va permettre de convertir de 3 lignes et 3 colonnes en une liste de 9 éléments.

np.zeros(), np.eyes() et np.ones()

Ces fonctions permettent de produire des matrices particulières:

  • np.zeros() crée un matrice ne contenant que des zéros sous la forme de flottants.
    • np.zeros(n) produit une matrice carrée de taille n contenant des zéros.
    • np.zeros((n, m)) produit une matrice de n lignes et m colonnes des zéros.
    • np.zeros((n, m), dtype=int) crée une matrice contenant des zéros sous forme d’entiers.
  • np.eyes() permet de créer une matrice avec 1 sur la diagonales. La syntaxe est similaire à celle de np.zeros().
  • np.ones() permer de créer une matrice avec des 1. La syntaxe est similaire à celle de np.zeros().

np.diag()

np.diag() permet de créer une matrice à partir d’un vecteur. Ce vecteur se trouve sur la diagonale de la matrice.

Par exemple:

>>> a=np.array([0, 1, 2, 3])
>>> np.diag(a)
array([[0, 0, 0, 0],
       [0, 1, 0, 0],
       [0, 0, 2, 0],
       [0, 0, 0, 3]])

np.random.rand()

Cette fonction produit un tableau de flottants aléatoires:

  • np.random.rand(6) produit un tableau de 6 flottants.
  • np.random.rand(6, 9) produit un matrice de 6 lignes et 9 colonnes avec des flottants choisis aléatoirement.

Fonctionnement général des index

L’utilisation des index est classique et similaire aux listes Python. Les index commencent à 0.

Par exemple:

  • Pour un tableau à 1 dimension, a[1] permet d’accéder au 2e élément de a:
    >>> a=np.array([1,2,3])
    >>> a[1]
    2
    
  • Pour un tableau à 2 dimensions, on utilise 2 coordonnées:
    >>> a=np.array([[1,2,3],[4,5,6],[7,8,9]])
    >>> a[1, 0]
    4
    

Slicing

La fonctionnalité de slicing valable pour les listes Python (voir list slicing) est aussi utilisable pour les tableaux numpy. Avec la syntaxe suivante, on peut extraire un tableau du tableau:

[<index de début>:<index de fin exclu>:<pas utilisé>]

Par exemple, si on considère le tableau suivant:

>>> a=np.array([1,2,3,4,5,6,7,8,9])
>>> a[2:4]
array([3, 4])

a[2:4] permet d’obtenir un tableau numpy à partir du 3e élément jusqu’au 5e élément (l’index commençant à 0).

En utilisant les index pour extraire des éléments d’un tableau numpy, on extrait une référence du tableau. Il n’y a pas de copie. Cela signifie que si on modifie un élément dans la référence, le tableau initial est aussi modifié.

Par exemple:

>>> b = a[2:4]
>>> b[0] = 100  # On modifie le 1er élément de b et donc le 3e élément de a
>>> a
array([  1,   2, 100,   4,   5,   6,   7,   8,   9])

Concernant les index, les règles sont similaires à celles des listes Python. Si on considère le tableau numpy suivant:

>>> a=np.array([1,2,3,4,5,6,7,8,9]) 

Alors:

  • [2:] permet de commencer à l’index 2 (3e élément) jusqu’au dernier:
    >>> a[2:]
    array([3, 4, 5, 6, 7, 8, 9])
    
  • [:3] permet de commencer du début jusqu’à l’index 2 (3e élément). L’index est exclu:
    >>> [:3] 
    array([1, 2, 3])
    
  • On peut utiliser des index négatifs: -1 signifie le 1er élément en partant de la fin de la liste.
    >>> a[-3]
    7
    
  • [:] désigne tous les éléments de la liste.
    >>> a[:]
    array([1, 2, 3, 4, 5, 6, 7, 8, 9])
    

Dans le cas des tableaux numpy [:] n’effectue pas une copie. Cette syntaxe permet d’extraire une référence vers le tableau d’origine:

>>> b = a[:]
>>> b[3] = 100
>>> a
array([  1,   2,   3, 100,   5,   6,   7,   8,   9])

Le tableau initial est modifié.

Dans le cas multidimensionnel

Dans le cas de tableaux à plusieurs dimensions, les index sont séparés par des virgules:

[<index dimension 1>, <index dimension 2>, <index dimension 3>, etc]

Ainsi si on considère le tableau suivant:

>>> a=np.array([
    [[1,2,3],[4,5,6],[7,8,9]],
    [[10,11,12],[13,14,15],[16,17,18]],
    [[19,20,21],[22,23,24],[25,26,27]]
])

On peut lire le tableau comme étant 3 tableaux imbriqués. Si on écrit a[1,1,1], on considère:

  • L’index 1 du 1er tableau contenant [[1,2,3],[4,5,6],[7,8,9]]; [[10,11,12],[13,14,15],[16,17,18]] et [[19,20,21],[22,23,24],[25,26,27]]. L’index 1 est [[10,11,12],[13,14,15],[16,17,18]].
  • L’index 1 du 2e tableau contenant [10,11,12],[13,14,15],[16,17,18]. L’index 1 est [13,14,15].
  • L’index 1 du 3e tableau contenant[13,14,15]. L’index 1 est 14.

Si on considère l’index a[1:,1,1], on applique la même logique que précédemment sur le 1er tableau. 1: correspond à tout après l’index 1 inclus soit: [[10,11,12],[13,14,15],[16,17,18]] et [[19,20,21],[22,23,24],[25,26,27]].

Le résultat est donc:

array([14, 23])

De même, l’index a[::2,1,1] correspond à lister les éléments du 1er tableau par pas de 2 soit le 1er et le 3e élément: [[1,2,3],[4,5,6],[7,8,9]] et [[19,20,21],[22,23,24],[25,26,27]].

On applique ensuite les index pour obtenir:

array([ 5, 23])

Fonctions et propriétés particulières

Quelques fonctions ou propriétés utiles.

dtype

Pour obtenir le type des éléments on peut utiliser dtype:

>>> a = np.array([1, 4, 5, 8])
>>> a.dtype
dtype('int64')

np.append()

Cette fonction permet de rajouter des valeurs à la fin d’un tableau. Le résultat est un nouveau tableau.

Par exemple:

>>> a=np.array([1, 2, 3, 4])
>>> np.append(a, [5, 6])
array([1, 2, 3, 4, 5, 6])

Quand on ne précise pas d’axe indiquant la dimension suivant laquelle l’ajout sera effectué, les données sont aplanies et sont utilisées suivant une seule dimension, par exemple:

>>> a=np.array([1, 2])
>>> np.append(a, [[4, 5, 6], [7, 8, 9]])
array([1, 2, 4, 5, 6, 7, 8, 9])

Si on précise l’argument axis, on peut préciser la dimension suivant laquelle on peut ajouter des éléments au tableau existant. En précisant l’axe, les dimensions initiales du tableau sont maintenues.

Par exemple:

>>> a=np.array([[1, 2, 3], [4, 5, 6]])
>>> np.append(a, [[7, 8, 9]], axis=0)
array([[1, 2, 3],
       [4, 5, 6],
       [7, 8, 9]])

[[1, 2, 3], [4, 5, 6]] et [[7, 8, 9]] peuvent être ajoutés car ils ont le même nombre de dimension. Ajouter [[1, 2, 3], [4, 5, 6]] et [7, 8, 9] ne fonctionnent pas car [7, 8, 9] a une dimension 1.

Suivant l’autre axe:

>>> a=np.array([[1, 2, 3], [4, 5, 6]])
>>> np.append(a, [[7, 8, 9], [10, 11, 12]], axis=1)
array([[ 1,  2,  3,  7,  8,  9],
       [ 4,  5,  6, 10, 11, 12]])

np.insert()

np.insert() permet d’insérer des valeurs dans un tableau en précisant l’index à partir duquel on effectue l’ajout. Le résultat est un nouveau tableau.

Par exemple:

>>> a=np.array([1, 2, 3, 4])
>>> np.insert(a, 2, [5, 6])
array([1, 2, 5, 6, 3, 4])

Les éléments sont rajoutés à partir de l’index 2.

Quand on ne précise pas d’axe indiquant la dimension suivant laquelle l’ajout sera effectué, les données sont aplanies et sont utilisées suivant une seule dimension.

Si on précise l’argument axis, on peut préciser la dimension suivant laquelle on peut ajouter des éléments au tableau existant. En précisant l’axe, les dimensions initiales du tableau sont maintenues.

Par exemple:

>>> a=np.array([[1, 2, 3], [4, 5, 6]])
>>> np.insert(a, 1, [7, 8, 9], axis=0)
array([[1, 2, 3],
       [7, 8, 9],
       [4, 5, 6]])

Suivant l’autre axe:

>>> a=np.array([[1, 2, 3], [4, 5, 6]])
>>> np.insert(a, 1, [7, 8], axis=1)
array([[1, 7, 2, 3],
       [4, 8, 5, 6]])

Si on utilise des scalaires, le comportement est différent. Le scalaire est rajouté en tant que vecteur suivant l’axe considéré.

Par exemple:

>>> a=np.array([[1, 2, 3], [4, 5, 6]])
>>> np.insert(a, 1, 7, axis=0)
array([[1, 2, 3],
       [7, 7, 7],
       [4, 5, 6]])
>>> np.insert(a, 1, 7, axis=1)
array([[1, 7, 2, 3],
       [4, 7, 5, 6]])

np.concatenate()

Cette fonction permet de concaténer des tableaux, par exemple:

>>> a = np.array([[1, 2, 3], [4, 5, 6]])
>>> b = np.array([[7, 8, 9]])
>>> c = np.array([[10, 11, 12]])
>>> np.concatenate((a, b, c))
array([[ 1,  2,  3],
       [ 4,  5,  6],
       [ 7,  8,  9],
       [10, 11, 12]])

Par défaut, l’argument axis=0. Si on indique axis=None alors les données sont aplanies:

>>> a = np.array([[1, 2, 3], [4, 5, 6]])
>>> b = np.array([7, 8, 9])
>>> c = np.array([10, 11, 12])
>>> np.concatenate((a, b, c), axis=None)
array([ 1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11, 12])

Suivant l’autre axe:

>>> a = np.array([[1, 2, 3], [4, 5, 6]])
>>> b = np.array([[7], [8]])
>>> np.concatenate((a, b), axis=1)
array([[1, 2, 3, 7],
       [4, 5, 6, 8]])

copy()

On l’a vu précédemment, la plupart des opérations sur les tableaux numpy produisent une référence sur le tableau d’origine. Pour effectuer une copie d’un tableau, on peut utiliser copy(). 2 syntaxes existent: <tableau numpy>.copy() ou np.copy(<tableau numpy>):

>>> a=np.array([1, 2, 3, 4, 5, 6, 7, 8, 9])
>>> b=a.copy()
>>> b
array([1, 2, 3, 4, 5, 6, 7, 8, 9])

On peut remplacer b=a.copy() par b=np.copy(a).

np.take()

np.take() permet d’extraire une matrice à partir d’un autre matrice en désignant les lignes ou les colonnes par leur index.

Par exemple si on considère la matrice:

>>> a = np.array([[1, 2, 3],
       [4, 5, 6],
       [7, 8, 9]])

On peut produire une nouvelle en considérant les lignes avec les index 0 et 2:

>>> a.take([0,2],axis=0)
array([[1, 2, 3],
       [7, 8, 9]])

En utilisant axis=1, on peut faire la sélection suivant les colonnes plutôt que les lignes:

>>> a.take([0,2],axis=1)
array([[1, 3],
       [4, 6],
       [7, 9]])

np.put()

np.put() permet de remplacer des éléments d’un tableau. Pour indiquer les index des éléments à remplacer, il faut considérer les index comme si les données étaient aplanies. np.put() effectue le remplacement directement sur le tableau fourni en paramètre.

Par exemple:

>>> a = np.array([[1, 2, 3],
       [4, 5, 6],
       [7, 8, 9]])
>>> np.put(a, [2, 0, 7], [100, 101, 102])
>>> a
array([[101,   2, 100],
       [  4,   5,   6],
       [  7, 102,   9]])

Le remplacement a été effectué au index 2, 0 et 7 comme si le tableau d’origine était disposé à plat:

[1, 2, 3, 4, 5, 6, 7, 8, 9] => [101, 2, 100, 4, 5, 6, 7, 102, 9]

Itération sur les éléments de la structure

Pour parcourir un tableau numpy, plusieurs solutions sont possibles avec l’opérateur for.

Itération directe

On peut parcourir directement toutes les dimensions d’un tableau numpy en utilisant plusieurs boucles for imbriquées.

Par exemple, si considère la tableau suivant comportant 2 dimensions:

>>> a = np.array([[1, 2, 3],
       [4, 5, 6],
       [7, 8, 9]])

Si on itère avec un boucle for:

for x in a:
    print(x)
[1 2 3]
[4 5 6]
[7 8 9]

On itère suivant la 1ère dimension. Pour itérer suivant la 2e dimension, il faut utiliser une 2e boucle for imbriquée:

for x in a:
    for y in x:
        print(y) 
1
2
3
4
5
6
7
8
9

Le gros inconvénient de cette méthode est qu’il faut connaître les dimensions du tableau pour savoir combien de boucles for imbriquées sont nécessaires pour parcourir toutes les valeurs.

Itération avec np.nditer()

L’intéret de np.nditer() est qu’il va permettre de parcourir tous les éléments du tableau quelque soit le nombre de dimension. Le parcours se fait en considérant les index à plat du tableau.

Par exemple si on considère le tableau:

array([[1, 2, 3],
       [4, 5, 6],
       [7, 8, 9]])

Disposé à plat:

[1, 2, 3, 4, 5, 6, 7, 8, 9]

Si on parcourt le tableau avec np.nditer():

for x in np.nditer(a):
    print(x)
1
2
3
4
5
6
7
8
9

np.nditer() possède de nombreuses options:

  • Pour parcourir le tableau dans le sens des colonnes plutôt que dans le sens des lignes avec l’option order='F', par exemple:
    for x in np.nditer(a, order='F'):
         print(x)
    
    1
    4
    7
    2
    5
    8
    3
    6
    9
    
  • Permettre la modification du tableau lors de son parcours avec l’option op_flags=['readwrite']:
    with np.nditer(a, op_flags=['readwrite']) as it:
        for x in it:
            x[...] = 2 * x
    
    >>> a
    array([[ 2,  4,  6],
           [ 8, 10, 12],
           [14, 16, 18]])
    
  • Pour avoir les index des valeurs parcourues, il faut utiliser l’option flags=['c_index'] pour effectuer le parcourt dans le sens des lignes et flags=['f_index'] pour parcourir dans le sens des colonnes.

    Par exemple:

    it = np.nditer(a, flags=['c_index'])
    for x in it:
        print(it.index, x)
    
    0 2
    1 4
    2 6
    3 8
    4 10
    5 12
    6 14
    7 16
    8 18
    

    Dans le sens des colonnes:

    it = np.nditer(a, flags=['f_index'])
    for x in it:
        print(it.index, x)
    
    0 2
    3 4
    6 6
    1 8
    4 10
    7 12
    2 14
    5 16
    8 18
    

Itération avec np.ndenumerate()

np.ndenumerate() permet de fournir un index sous la forme d’un tuple.

Par exemple si on considère le tableau:

a = np.array([[1, 2, 3],
       [4, 5, 6],
       [7, 8, 9]])

On peut le parcourir de cette façon et obtenir les index des valeurs:

for index, x in np.ndenumerate(a):
    print(index, x)
(0, 0) 1
(0, 1) 2
(0, 2) 3
(1, 0) 4
(1, 1) 5
(1, 2) 6
(2, 0) 7
(2, 1) 8
(2, 2) 9

La variable index est un tuple. On peut utiliser les différentes valeurs avec index[0] et index[1] dans le cas d’un tableau à 2 dimensions:

for index, x in np.ndenumerate(a):
    print(index[0], index[1], x)

Opérations sur les tableaux numpy

Il est possible d’appliquer facilement des opérations mathématiques sur les tableaux numpy.

Par exemple, si on considère le tableau:

>>> a = np.array([1, 2, 3, 4, 5, 6])
>>> 2*a
array([ 2,  4,  6,  8, 10, 12])

Ce type d’opération est applicable sur les tableaux quelque soit leur dimension:

>>> a = np.array([[1, 2, 3],
       [4, 5, 6],
       [7, 8, 9]])
>>> 2*a
array([[ 2,  4,  6],
       [ 8, 10, 12],
       [14, 16, 18]])

On peut appliquer d’autres opérations comme:

  • +, -, / ou * à condition que les tableaux soient de même taille. Ces opérations sont appliquées sur les éléments des tableaux avec le même index.
  • Opérations booléennes comme and, or et le “ou exclusif” avec np.logical_xor(a,b).
  • Appliquer des opérateurs de comparaison comme ==, <, >, <=, >= et !=. Ces opérateurs sont équivalents à np.equal(a, b), np.less(a, b), np.greater(a, b), np.less_equal(a,b), np.greater_equal(a,b) et np.not_equal(a,b).
  • Le produit matriciel avec np.dot().
  • Le produit scalaire np.vdot(a,b)

D’autres fonctions permettent d’effectuer des traitements sans avoir à parcourir le tableau en utilisant des boucles, par exemple:

  • Obtenir le minimum parmi les valeurs du tableau avec np.amin(). Pour obtenir le minimum par ligne: np.amin(a, axis=0); le minimum par colonne: np.amin(a, axis=1).
  • Obtenir le maximum parmi les valeurs du tableau avec np.amax(). Pour obtenir le minimum par ligne: np.amax(a, axis=0); le minimum par colonne: np.amax(a, axis=1).
  • Obtenir l’index du minimum en disposant les données à plat du tableau avec np.argmin(). Pour obtenir les index des minimums par ligne: np.argmin(a, axis=0); les index des minimums par colonne: np.argmin(a, axis=1).
  • Obtenir l’index du maximum en disposant les données à plat du tableau avec np.argmax(). Pour obtenir les index des minimums par ligne: np.argmax(a, axis=0); les index des minimums par colonne: np.argmax(a, axis=1).
  • Obtenir la somme des éléments du tableau avec np.sum(); la somme par ligne des éléments avec np.sum(a, axis=0); la somme par colonne des éléments avec np.sum(a, axis=1).
  • Obtenir le produit des éléments du tableau avec np.prod().
  • Obtenir la moyenne des éléments du tableau avec np.mean().

Installer des packages Python avec pip

pip est un gestionnaire de packages permettant l’installation de dépendances en Python. Ce n’est pas le seul gestionnaire mais c’est le plus recommandé, il en existe d’autres comme:

  • Homebrew (pour macOS),
  • conda (connu avec les installateurs Miniconda et Anaconda pour installer un environnement Python complet).
  • pipenv permettant de rassembler en un seul gestionnaire plusieurs gestionnaires.

Pip signifie “Pip installs Python” ou “PIP installs Packages”. Il a pour but comme tous les gestionnaires de packages de proposer des commandes communes pour installer, désinstaller ou mettre à jour les dépendances d’un projet en prenant en compte les différentes conditions de versions. Par défaut, il permet de télécharger les packages à partir de PyPI mais il est possible de configurer d’autres repositories pour, par exemple, télécharger à l’intérieur d’une infrastructure d’entreprise.

Quel pip utiliser ?

Comme pour Python, pip peut être installé de différentes façons. Comme les différentes méthodes d’installation peuvent placer pip à des chemins différents, il peut subsister plusieurs versions de pip sur le même machine.

Chemin de pip

On peut vérifier les différents emplacements possibles en tapant:

  • Windows:
    > where pip
    
  • Linux:
    • Pour afficher tous les répertoires où pip se trouve:
      % whereis pip
      
    • Pour indiquer le chemin actuel utilisé:
      % which pip
      

Pour vérifier la version de pip:

% pip --version 

Utiliser pip avec un environnement virtuel

On peut utiliser pip:

  • Directement: suivant la méthode utilisée pour installer pip (par exemple avec Miniconda ou avec un autre gestionnaire de package). Son emplacement est du type:
    • Sur Windows: C:/Program Files/Miniconda3-Windows-x86_64/Script/pip.exe
    • Sur Linux: /home/<user>/miniconda3/bin/pip3

    Cette méthode est déconseillée car la version de pip peut être figée et liée à la façon dont Python est installée. De cette façon, on peut être amené à utiliser une version obsolète de pip. Il est préférable d’utiliser cette méthode comme amorce et privilégier l’utilisation d’un environnement virtuel.

  • Exécuter pip en tant que module: cette méthode permet de garantir que la version de pip qui est utilisée est en accord avec la version de Python utilisée, par exemple:
    % python -m pip
    
  • Exécuter dans un environnement virtuel: cette méthode permet d’installer une version spécifique pour un projet donné. Elle permet de mettre à jour facilement pip.

Pour créer un environnement virtuel dans un répertoire:

python -m venv <chemin du répertoire> 

Par exemple:

% python -m venv venv 

Pour activer cet environnement sur l’invite de commandes ou le terminal courant:

  • Sur Windows: <chemin de l'env. virtuel>\Scripts\activate
  • Sur Linux: source <chemin de l'env>/bin/activate

Dans notre cas:

  • Sur Windows:
    > venv\Scripts\activate.bat
    
  • Sur Linux:
    % source venv/bin/activate
    

L’environnement est ensuite activé, ce qui signifie que python et pip utilisés sont désormais dans le répertoire de l’environnement virtuel. De même, si on installe un package avec pip, il le sera seulement dans l’environnement virtuel.

Si on tape:

where pip 

Le résultat indique au moins 2 répertoires dont le premier est celui de l’environnement virtuel:

  • Sur Windows:
    > where pip 
    <chemin de l'env. virtuel>\Scripts\pip.exe 
    C:\Program Files\Miniconda<version>\Scripts\pip.exe 
    
  • Sur Linux:
    % whereis pip 
    pip: 
        <chemin de l'env. virtuel>/bin/pip3.9 
        <chemin de l'env. virtuel>/bin/pip 
        /home/<user>/miniconda<version>/bin/pip 
    

Par exemple, si on installe un package dans l’environnement virtuel:

% pip install numpy 

On peut voir qu’il est installé dans l’environnement virtuel:

<chemin de l'env. virtuel>/Lib/site-packages/numpy 

Pour désactiver l’environnement virtuel pour l’invite de commande ou du terminal courant, il faut juste taper:

% deactivate 

A ce stage, les chemins de python et de pip qui seront utilisés ne seront plus ceux de l’environnement virtuel:

% where pip 
  • Sur Windows:
    > where pip 
    C:\Program Files\Miniconda<version>\Scripts\pip.exe 
    
  • Sur Linux:
    % where pip 
    pip: 
        /home/<user>/miniconda<version>/bin/pip 
    

Les commandes pip

Les commandes principales sont:

  • pip install: pour installer des packages.
  • pip uninstall: pour désinstaller des packages.
  • pip list: pour lister les packages installés.
  • pip search: pour chercher un package dans un index de packages.
  • pip show: pour indiquer des informations concernant un package.
  • pip freeze: pour lister les packages installés et permettre de stocker la liste dans un fichier requirements.txt
  • pip wheel: pour construire un package d’un projet et télécharger les dépendances de ce projet.
  • pip cache: pour manipuler le cache de pip.
  • pip config: pour configurer pip.

Dans cet article, on ne traitera que ces commandes. On peut voir la liste exhaustive des commandes sur pip.pypa.io/en/stable/cli/.

pip install

Un package peut être installé à partir de 4 sources:

  • PyPI (ou un autre repository suivant la configuration): c’est le type d’installation le plus courant. Cette méthode permet de récuperer et d’installer des packages tiers.
  • A partir d’un gestionnaire de versions (comme Git): si le repository dans Git possède un fichier setup.py, il est possible d’installer un package directement à partir du code source. Cette méthode est plutôt simple d’utilisation et convient bien si le projet ne se trouve pas dans un repository Python comme PyPI.
  • A partir d’un répertoire: si le projet comprend un fichier setup.py, il est possible de l’installer directement à partir d’un répertoire. Cette méthode convient bien pour tester l’installation dans le cadre d’un projet en développement.
  • A partir d’un fichier .zip ou .tar.gz.

D’une façon générale, pour installer un package ou plusieurs packages, il faut exécuter:

pip install <noms des packages> 

Pour séparer les noms des packages, il suffit d’utiliser un espace.

L’installation des packages se fait en 4 étapes, par exemple:

% pip install numpy 
Collecting numpy 
  Downloading numpy-1.23.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (13.9 MB) 
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 13.9/13.9 MB 16.1 MB/s eta 0:00:00 
Installing collected packages: numpy 
Successfully installed numpy-1.23.1 

Les étapes de l’installation sont:

  • Prise en compte des paramètres.
  • Résolution des dépendances: les packages sont téléchargés ou récupérer à partir du cache.
  • Construction des packages wheels pour toutes les dépendances où c’est possible. Cette étape n’est pas effectuée s’il n’y a pas de packages wheel ou s’ils sont dans le cache. Execution du fichier setup.py des packages.
  • Installation des packages

Installer à partir d’un fichier

On peut installer un package à partir d’un fichier tar.gz ou un fichier wheel (.whl):

pip install <emplacement du fichier> 

Indiquer où les packages sont installés

Par défaut le répertoire utilisé pour stocker les packages installés est de type:

  • Sur Windows:
    C:\Program Files\Miniconda<version>\lib\site-packages
    
  • Sur Linux:
    /home/<user>/miniconda<version>/lib/site-packages 
    

Dans Le cas où on utilise un environnement virtuel, le chemin sera du type:

<chemin du l'env. virtuel>/lib/site-packages 

Pour trouver le chemin du répertoire site-packages, il faut exécuter:

% python 
>>> import site 
>>> site.getsitepackages() 
['<chemin du l'env. virtuel>/lib/python3.9/site-packages'] 

Dans le répertoire site-packages, on peut trouver tous les packages avec des répertoires correspondant au nom du package et des informations relatives aux packages dans un répertoire de type:

<chemin de site-packages>/<nom package>-<version>.dist-info 

Par exemple pour numpy en version 1.19.5, le répertoire contenant des informations sur le package est:

<chemin de site-packages>/numpy-1.19.5.dist-info 

Conditions sur les versions

On peut indiquer des conditions de versions sur les packages. Il est possible d’indiquer plusieurs conditions en les séparant par une virgule. Les conditions doivent être indiquées avec la syntaxe <opérateur> <version concernée>. D’une façon générale, pip essaie d’installer la dernière version d’un package en prenant en compte les différentes conditions.

Les opérateurs sont:

  • == pour indiquer une version précise. Si la version ne peut être satisfaite alors l’installation ne pourra aboutir.
  • != pour indiquer l’incompatibilité avec une version précise.
  • <=, <, >, >= pour indiquer que la version du package doit être, respectivement, inférieure ou égale, strictement inférieure, strictement supérieure ou supérieure ou égale à une version donnée.
  • ~= si M est la version majeure et m la version mineure, cette condition permet d’indiquer une condition équivalente à >= M.m, == M.*.

    Par exemple:

    • ~= 1.3 est équivalent à:
      >= 1.3, == 1.*
    • L’équivalence peut porter sur des versions comprenant MAJEURE.MINEURE.PATCH:
      ~= 1.1.3 est équivalent à:
      >= 1.1.3, == 1.1.*
    • === effectue une comparaison des versions sous la forme de chaîne de caractères sans prendre en compte la sémantique dans les numéros de versions.

Par exemple:

% pip install 'numpy>1.0, <=1.5'

On peut indiquer plusieurs conditions de version de cette façon:

% pip install 'numpy>1.0, <=1.5'

Dans le cas où on veut installer plusieurs packages:

% pip install 'numpy>1.0, <=1.5' pandas 

Identifiants des versions

En Python, les identifiants de versions respectent quelques règles qui ne sont pas forcément appliquées pour d’autres langages. Ces règles sont détaillées dans les spécifications PEP440.

Les versions finales sont indiquées sous la forme classique <majeure>.<mineure> ou <majeure>.<mineure>.<patch> en accord avec le semantic versioning (cf. semver.org). Pour les versions intermédaires (alpha, beta, release candidate, prelease) Python permet d’indiquer plus de précisions dans l’identifiant des versions à condition de respecter les règles suivantes:

  • Version pré-release:
    • a ou alpha: 1.2a1 ou 1.2alpha1 (dans ce cas la version finale sera 1.2); 1.2.4a1 ou 1.2.4alpha1 (dans ce cas la version finale sera 1.2.4).
    • b ou beta: 3.2b2 ou 3.2beta2 (dans ce cas la version finale sera 3.2); 3.2.4b1 ou 3.2.4beta1 (dans ce cas la version finale sera 3.2.4).
    • c, rc, pre ou preview: 1.5c2, 1.5rc2, 1.5pre2, 1.5preview2; 1.5.6c2, 1.5.6rc2, 1.5.6pre2, 1.5.6preview2;

    D’autres règles s’appliquent:

    • Il est possible d’utiliser les caractères de séparation - ou _ par exemple 1.2.7-a2 ou 1.2.7_a2 toutefois la forme courante est 1.2.7a2.
    • En cas d’omission du numéro, 0 est utilisé. Ainsi 1.2.7a est équivalent à 1.2.7a0.
  • Version post-release: avec .postN, par exemple 1.2.post0 (la version précédente était 1.2); 1.2.7.post2 (les versions précédentes étaient 1.2.7 et 1.2.7.post1).
    • Il est possible d’utiliser les caractères de séparation - ou _ par exemple 1.2.7-post2 ou 1.2.7_post2 toutefois la forme courante est 1.2.7.post2.
    • En cas d’omission du numéro, 0 est utilisé. Ainsi 1.2.7.post est équivalent à 1.2.7.post0.
  • Version de développement: avec .devN, par exemple 3.1.dev2 (la version finale sera 3.1); 3.1.5.dev1 (la version finale sera 3.1.5).

Dans le cas où des conditions avec des opérateurs d’inégalité sont appliquées, l’ordre des versions s’applique suivant l’identifiant:

  • Les versions de pré-release comme alpha, beta, release candidate précédent les versions finales.
  • Les versions de post-release succèdent aux versions stables.
  • Les versions de développement précédent les versions finales.

Mode éditable

Ce mode peut être utile en développement car il permet d’installer un package que l’on développe de façon à pouvoir accéder aux sources facilement. Quand on installe un package de cette façon, un lien est créé dans le répertoire site-packages. Ce lien est de type:

<chemin site-packages>/<nom du package>.egg-link 

Ces liens sont des fichiers permettant à l’interpréteur python de faire un lien entre le nom du package et l’emplacement des fichiers sources.

Pour installer un package à partir d’un répertoire avec le mode éditable:

pip install -e <chemin du répertoire> 

Par exemple, pour installer à partir du répertoire courant:

% pip install -e .  

Pour illustrer l’installation à partir d’un repository GitHub, on considère le repository: github.com/msoft/python_package_example.
Ce repository contient les fichiers suivants:

├── LICENSE 
├── PeopleCounter 
│   ├── Counter.py 
│   ├── __init__.py 
│   └── ScientistRepository.py 
├── README.md 
└── setup.py 

Le fichier setup.py contient le code suivant:

from distutils.core import setup 

with open("README.md", "r") as fh: 
    description = fh.read() 

setup(name='PeopleCounter', 
      version='1.0', 
      description='Python package example', 
      author='MM', 
      author_email='', 
      packages=['PeopleCounter'], 
      long_description=description, 
      long_description_content_type="text/markdown", 
      url="https://github.com/msoft/python_package_example", 
      license='MIT', 
      python_requires='>=3.8', 
      install_requires=[ 'numpy' ] 
     ) 

Si on installe à partir du repository en exécutant la commande suivante:

% pip install git+https://github.com/msoft/python_package_example.git 

Les fichiers sont installés de la façon suivante:

  • Les fichiers .py sont installés dans <chemin env. virtuel>/lib/python3.9/site-packages/PeopleCounter.
  • Les fichiers .pyc contenant le bytecode CPython correspondant au code Python dans <chemin env. virtuel>/lib/python3.9/site-packages/PeopleCounter/__pycach__ (plus de détails sur CPython plus bas).
  • Les métadonnées du package sont dans: <chemin env. virtuel>/lib/python3.9/site-packages/PeopleCounter-1.0.dist-info.

Si on effectue l’installation en mode éditable en exécutant la commande suivante:

% pip install -e git+https://github.com/msoft/python_package_example.git#egg=PeopleCounter 

Il n’existe pas de code CPython, les fichiers sont installés de la façon suivante:

  • Les fichiers .py sont installés dans <chemin env. virtuel>/src/peoplecounter/
  • Un lien .egg-link est placé dans le répertoire <chemin env. virtuel>/lib/python3.9/site-packages/PeopleCounter.egg-link. Ce fichier contient le chemin du répertoire contenant les sources:
    <chemin env. virtuel>/src/peoplecounter 
    

    Ce lien est utilisé par l’interpréteur Python pour faciliter les imports.

Installation à partir d’un repository GitHub

Il faut exécuter une commande du type:

pip install git+<adresse .git du repo> 

En mode éditable:

pip install -e git+<adresse .git du repo>#egg=<nom du package> 

Par exemple:

% pip install git+https://github.com/msoft/python_package_example.git 

En mode éditable:

% pip install -e git+https://github.com/msoft/python_package_example.git#egg=PeopleCounter 

Quelques options courantes:

  • Installer à partir d’un fichier requirements.txt (contenant les dépendances voir pip freeze):
    pip install -r <chemin du fichier requirements.txt> 
    

    Ou

    pip install --requirement <chemin du fichier requirements.txt> 
    
  • Permettre d’installer une version en pré-release:
    pip install --pre <nom du package> 
    
  • Pour ne pas installer de dépendances:
    pip install --no-deps <nom du package> 
    
  • Pour simuler l’exécution:
    pip install --dry-run <nom du package> 
    
  • Pour réinstaller des packages déjà installés:
    pip install --force-reinstall <nom du package> 
    
  • Pour installer à partir d’un répertoire local sans utiliser des index comme PyPI:
    pip install --no-index --find-links <chemin répertoire local> 
    

Le répertoire local sera listé pour trouver les dépendances. Si le chemin indiqué est une URL ou un fichier HTML, les liens du fichiers seront utilisés pour trouver les dépendances.

Pour effectuer la mise à jour d’un package

La mise à jour implique que le package sera supprimé puis réinstaller, il faut exécuter:

pip install --upgrade <nom du package>

Ou

pip install -U <nom du package> 

Pour mettre à jour pip:
Il est fortement conseillé d’utiliser pip sous la forme d’un module pour mettre à jour pip:

% python -m pip install -U pip  

pip download

La commande pip download effectue le même traitement de résolution des dépendances que pip install. La différence est que pip download n’installe pas les packages, mais permet de les télécharger. Par défaut, les packages sont installés dans le répertoire courant. Les dépendances des packages peuvent aussi être téléchargées si elles ne sont pas déjà installées.

Si un package à télécharger existe sous la forme wheel, un fichier .whl compatible sera téléchargé sinon dans le cas d’un projet contenant des fichiers .py et un fichier setup.py, un fichier .zip sera généré.

Comme pour pip install, il est possible de télécharger un package à partir de sources différentes:

  • Des repositories comme PyPI:
    pip download <nom des packages> 
    

    Sans indications supplémentaires, le téléchargement sera effectué dans le répertoire courant.

    Pour indiquer explicitement le répertoire de destination, il faut utiliser l’option -d ou --dest, par exemple:

    pip download numpy -d <chemin répertoire> 
    

    Ou

    pip download numpy --dest <chemin répertoire> 
    

    Par défaut, seules les versions stables sont téléchargées. Pour inclure les versions pre-releases, il faut utiliser l’option --pre:

    pip download --pre <nom des packages> 
    

    Pour effectuer les téléchargements de packages indiqués dans un fichier requirement (obtenu par exemple en utilisant pip freeze):

    pip download -r <fichier requirement> 
    

    Ou

    pip download --requirement <fichier requirement> 
    
  • Un VCS comme Git:
    pip download git+<adresse .git du repo> 
    

    Les dépendances existant sous la forme de fichier wheel .whl seront téléchargés et le projet dans le repo Git sera téléchargé sous la forme d’un fichier .zip.

  • Directement à partir d’un répertoire:
    pip download <chemin du répertoire> 
    

    Cette commande permet de télécharger toutes les dépendances.

  • A partir d’une archive .zip ou .tar.gz:
    pip download <chemin de l'archive> 
    

    Cette commande permet de télécharger toutes les dépendances et de copier l’archive dans le répertoire de destination.

pip uninstall

Pour désinstaller un package à partir de son nom:

pip uninstall <nom du package> 

Pour éviter la question de confirmation:

pip uninstall --yes <nom du package> 

Ou

pip uninstall -y <nom du package> 

pip list

Permet de lister les packages installés. Par défaut les packages éditables sont affichés et les packages en pré-release ne sont pas affichés:

pip list  

Pour lister uniquement les packages éditables:

pip list -e

Ou

pip list --editable 

Pour lister les packages obsolètes:

pip list -o

Ou

pip list --outdated 

Pour lister aussi les packages en prerelease:

pip list --pre 

Permet de chercher un package dans PyPI par défaut:

pip search <nom du package> 

Il faut noter que dans le code de PyPI, cette commande est désactivée pour limiter les accès aux serveurs et ne permet pas de renvoyer des résultats. On obtient systématiquement l’erreur suivante:

ERROR: XMLRPC request failed [code: -32500] 
RuntimeError: PyPI's XMLRPC API is currently disabled due to unmanageable load and will be deprecated in the near future. See https://status.python.org/ for more information. 

Cette commande est à privilégier pour des index privés comme à l’intérieur de l’infrastructure d’une entreprise.

Pour utiliser un autre index:

pip search -i <url> <nom du package>

Ou

pip search --index <url> <nom du package> 

pip show

Permet d’indiquer des informations concernant un package installé:

pip show <nom du package> 

Pour afficher la liste des fichiers installés:

pip show -f <nom du package>

Ou

pip show -files <nom du package> 

pip freeze

Permet d’indiquer les packages installés. Cette commande est particulièrement utile pour figer la version des dépendances d’un projet.

Pour lister les packages installés dans un fichier:

pip freeze > <nom du fichier>  

Pour installer les packages listés dans le fichier généré;

pip install -r <nom du fichier>  

Par défaut les packages distribute, pip, setuptools, wheel ne sont pas listés. L’option --all permet de lister ces packages:

pip freeze --all 

Pour exclure le package en mode éditable:

pip freeze --exclude-editable 

pip wheel

pip wheel permet de construire le package wheel .whl d’un projet et de télécharger les dépendances de ce projet. La commande pip wheel utilise la bibliothèque wheel pour construire les packages. La bibliothèque wheel utilise ensuite setuptools.

La recherche des dépendances se fait, par défaut, comme pour l’instruction pip install. Sans précision, elle se fait à partir des repositories comme PyPI.

Pour construire un package wheel et télécharger les dépendances dans un projet, on peut exécuter directement:

pip wheel <chemin du projet> 

Pour que le package wheel soit construit, il faut que le projet comporte un fichier setup.py (voir package wheel .whl. Les packages seront placés dans le répertoire dans lequel l’instruction est exécutée.

On peut préciser des options particulières:

  • Pour indiquer le répertoire de destination:
    pip wheel <chemin du répertoire> -w <répertoire destination .whl>
    

    Ou

    pip wheel <chemin du répertoire> --wheel-dir <répertoire destination .whl>
    
  • Pour effectuer la construction du package à partir d’un projet dans un repository Github:
    pip wheel git+<adresse .git du repo> 
    
  • Pour utiliser un fichier requirements.txt:
    pip wheel -r <chemin fichier requirements.txt> 
    

    Dans le cas où un projet éditable est utilisé dans un répertoire différent de:

    • <répertoire de l'environnement virtuel>/src ou
    • <répertoire courant>/src

    On peut indiquer le chemin du projet avec l’option --src:

    pip wheel --src <chemin du répertoire> 
    
  • On peut utiliser --pre comme pour pip install pour indiquer la prise en compte des packages en pré-release ou en développement.
  • Dans le cas où certains packages ne se trouvent pas le repository indiqué en configuration mais localement, on peut indiquer le chemin du répertoire dans lequel se trouve les packages en utilisant l’option -f ou --find-links:
    pip wheel <chemin du répertoire du projet> -f <chemin packages> 
    

    Ou

    pip wheel <chemin du répertoire du projet> --find-links <chemin packages> 
    

    Si le chemin des packages est un répertoire, le répertoire sera listé pour trouver les dépendances. Si le chemin des packages est une URL ou un fichier HTML, les liens du fichiers seront utilisés pour trouver les dépendances.

A titre d’exemple d’utilisation de l’instruction pip wheel, on considère un projet simple possédant 2 dépendances:

  • peoplecounter qui est un package construit localement provenant de l’exemple plus haut. peoplecounter nécessite le package numpy.
  • numpy qui sera téléchargé dans PyPI qui est une dépendance indirecte car peoplecounter nécessite son téléchargement.

Le projet comporte 2 fichiers:

  • test.py qui contient le code Python utilisant peoplecounter et
  • setup.py qui permet de construire le package du projet.

Le fichier test.py contient le code:

from peoplecounter import ScientistRepository 
from peoplecounter import Counter 

scientistRepo = ScientistRepository() 
scientistRepo.printMembers() 
 
counter = Counter() 
counter.countScientists() 

Le fichier setup.py contient:

from distutils.core import setup 

setup(name='TestPackage', 
      version='1.0.0', 
      description='Python package example', 
      install_requires=[ 'peoplecounter' ] 
     ) 

On peut voir dans ce fichier la dépendance vers peoplecounter.

Si on exécute l’instruction suivante dans le répertoire où se trouve setup.py:

/home/user/python/test% pip wheel . -w results 

L’option -w results est rajoutée pour que les packages soient téléchargés dans le répertoire results.

Le résultat est:

Processing /home/user/python/test 
  Preparing metadata (setup.py) ... done 
ERROR: Could not find a version that satisfies the requirement peoplecounter (from testpackage) (from versions: none) 
ERROR: No matching distribution found for peoplecounter 

L’instruction échoue car peoplecounter est un package local et qu’aucun repository ne permet de le retrouver. On rajoute l’option -f <répertoire> pour indiquer le répertoire ../other_packages dans lequel se trouve le package .whl peoplecounter:

/home/user/python/test% pip wheel . -w results -f ../other_packages 

Le résultat:

Looking in links: ../other_packages 
Processing /home/user/python/test 
  Preparing metadata (setup.py) ... done 
Processing /home/user/python/other_packages/peoplecounter-1.0.0-py3-none-any.whl 
Collecting numpy 
  Using cached numpy-1.23.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl (13.9 MB) 
Saved ./results/peoplecounter-1.0.0-py3-none-any.whl 
Saved ./results/numpy-1.23.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl 
Building wheels for collected packages: TestPackage 
  Building wheel for TestPackage (setup.py) ... done 
  Created wheel for TestPackage: filename=TestPackage-1.0.0-py3-none-any.whl size=1077 sha256=dbe761785e430537171a0017a3df1d235cafd44110797e864ea92b5d03d55b92 
  Stored in directory: /tmp/pip-ephem-wheel-cache-xr1jlt4g/wheels/06/fa/73/05ae28860a3649aff8701fe92444de7cf3d792fc7434a6b138 
Successfully built TestPackage 

Dans le répertoire results, on peut trouver les packages suivants:

/home/user/python/test% ls results 
numpy-1.23.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl 
peoplecounter-1.0.0-py3-none-any.whl 
TestPackage-1.0.0-py3-none-any.whl 

On peut voir que:

  • numpy a été téléchargé. Ce n’est pas une dépendance directe du projet TestPackage toutefois il a été téléchargé car c’est une dépendance du package peoplecounter.
  • peoplecounter puisque c’est la seule dépendance directe de TestPackage.
  • TestPackage: ce package a été construit conformément au fichier setup.py.

pip cache

Pour minimiser le trafic réseau, pip stocke les packages wheel téléchargés dans un cache.

pip stocke les packages dans le cache de 2 façons:

  • Les packages téléchargés en utilisant un index comme PyPI et
  • Les packages construits, par exemple, à partir d’un repository Github.

Quelques opérations concernant le cache:

  • Pour connaître le répertoire du cache, on peut exécuter:
    % pip cache dir 
    
  • Pour avoir des informations sur ce cache:
    % pip cache info 
    Package index page cache location: /home/user/.cache/pip/http 
    Package index page cache size: 88.9 MB 
    Number of HTTP files: 54 
    Locally built wheels location: /home/user/.cache/pip/wheels 
    Locally built wheels size: 2.5 kB 
    Number of locally built wheels: 1 
    
  • Pour supprimer tout le contenu du cache:
    % pip cache purge 
    
  • Pour supprimer des packages avec un nom particulier:
    pip cache remove <pattern de recherche> 
    
  • Pour ajouter un package construit localement dans le cache, il faut l’installer, par exemple, à partir d’un tag ou d’un commit Git:
    Si on considère le repository github.com/msoft/python_package_example du package peoplecounter, on peut l’installer en exécutant:

    % pip install peoplecounter@git+https://github.com/msoft/python_package_example.git@f21d733cb72a59aa537ca0b369f46887383451c8 
    

    A ce moment le package sera rajouté dans le cache en tant que package construit localement.

  • Pour lister les packages construits localement;
    pip cache list <pattern de recherche> 
    

    Avec cette syntaxe, les packages seront affichés par nom:

    Cache contents: 
     - peoplecounter-1.0.0-py3-none-any.whl (2.3 kB) 
    

    On peut afficher le chemin de ces packages en exécutant:

    % pip cache list --format abspath  
    /home/user/.cache/pip/wheels/28/01/8e/fef2941029595fef189e0aeb739117267d9835751dcbf78a62/peoplecounter-1.0.0-py3-none-any.whl 
    

pip config

La configuration de pip peut être paramétrer de 3 façons du plus prioritaire au moins prioritaire:

  1. Par des options lorsqu’une commande est exécutée avec --<indication du paramètre>.

    Par exemple, si considère le paramètre --progress-bar <on ou off>.

    La configuration par option surcharge toutes les autres méthodes.

  2. Avec des variables d’environnement. Le nommage se fait en utilisant des majuscules, avec le préfixe PIP_ et en remplaçant - par _.

    La configuration par variable d’environnement surcharge la méthode par fichier de configuration.

    Par exemple l’équivalent du paramètre --progress-bar <on ou off> est:

    PIP_PROGRESS_BAR 
    
  3. Avec un fichier de configuration.
    Si le paramètre affecte plusieurs commandes, il sera placé dans la catégorie [global]:

    [global] 
    progress-bar = on 
    

    Dans le cas où le paramètre est spécifique à une commande, il sera placé dans la catégorie correspondant à la commande. Par exemple, si on considère la commande pip download et l’option --progress-bar <on ou off>:

    [download] 
    progress-bar = on 
    

    La configuration par fichier peut être surchargée par toutes les autres méthodes. La valeur d’un paramètre affecté au niveau global peut être surchargée par une valeur affectée pour une commande spécifique.

Configuration par fichier

Il existe 3 niveaux de configuration par fichier pour pip. Pour un niveau donné, si aucune valeur n’est précisée, il hérite de la valeur indiquée dans un niveau supérieur. Si une valeur est précisée, elle surcharge la valeur du niveau supérieur. Par défaut, c’est le niveau utilisateur qui est affecté.

Les 3 niveaux de configurations sont:

  • Global: concerne tout le système pour tous les utilisateurs et tous les environnements. Il faut utiliser --global pour affecter des paramètres pour ce niveau.
  • Utilisateur: concerne un utilisateur en particulier et tous les environnements de cet utilisateur. Il faut utiliser --user pour affecter des paramètres pour ce niveau.
  • Site: concerne un environnement en particulier. Il faut utiliser --site pour affecter des paramètres pour ce niveau.

Pour voir le détail de la configuration par fichier, on peut exécuter:

pip config list  

Ou pour avoir plus de détails:

pip config -v list 

On peut voir la liste des fichiers choisis en tapant:

pip config -v debug 

Avec cette dernière commande, on peut voir les chemin des fichiers de configuration suivant les niveaux.

Par exemple:

Windows Niveau global C:\ProgramData\pip\pip.ini
Niveau utilisateur C:\Users\<utilisateur>\pip\pip.ini ou
C:\Users\<utilisateur>\AppData\Roaming\pip\pip.ini
Niveau site dans le cas de miniconda C:\Program Files\miniconda3-windows-x86_64\pip.ini
Linux Niveau global /etc/xdg/pip/pip.conf
/etc/pip.conf
Niveau utilisateur /home/<utilisateur>/.pip/pip.conf
/home/<utilisateur>/.config/pip/pip.conf

Dans le cas d’un environnement virtuel, le niveau site est remplacé par le fichier de l’environnement. Par exemple:

  • Sur Windows: <chemin environnement virtuel>\pip.ini
  • Sur Linux: <chemin environnement virtuel>/pip.conf

Dans les fichiers de configuration, les paramètres sont identifiés suivant:

  • le nom de la catégorie: [global] si le paramètre concerne plusieurs commandes ou la catégorie correspondant à la commande si le paramètre est spécifique à une commande. Par exemple, si un paramètre concerne la commande pip download alors la catégorie est download.
  • le nom du paramètre.

Par exemple, si on considère l’option --index-url http://example.org devant s’appliquer seulement sur la commande pip download, le paramétrage dans un fichier sera:

[download] 
index-url = http://example.org 

PIP_CONFIG_FILE

On peut utiliser une variable d’environnement nommée PIP_CONFIG_FILE pour indiquer le chemin d’un fichier de configuration. Ce fichier sera utilisé en priorité par rapport aux autres niveaux global, utilisateur ou site.

Liste des commandes pour modifier la configuration par fichier

La liste des commandes de pip config pour éditer la configuration est:

  • edit pour éditer la configuration avec un éditeur. Cette commande renvoie l’erreur suivante si on n’indique pas un éditeur:
    % pip config edit 
    ERROR: Could not determine editor to user 
    

    Pour préciser l’éditeur:

    • Sur Windows: pip config edit --editor notepad.exe .
    • Sur Linux: pip config edit --editor vi
  • get pour récupérer la valeur d’un paramètre de configuration. Le paramètre est identifié avec la syntaxe:
    <catégorie>.<option>

    Ainsi pour obtenir la valeur du paramètre:

    [download] 
    index-url = http://example.org 
    

    Il faut taper:

    pip config get download.index-url 
    
  • set pour affecter une valeur à un paramètre de configuration. Le paramètre est identifié avec la syntaxe: <catégorie>.<option>.

    Ainsi pour affecter une valeur au paramètre:

    [download] 
    index-url = ...
    

    Il faut exécuter la commande:

    % pip config set download.index-url http://example.org 
    
  • unset pour supprimer la valeur d’un paramètre de configuration. Le paramètre est identifié avec la syntaxe: <catégorie>.<option>.

    Ainsi pour supprimer la valeur du paramètre:

    [download] 
    index-url = http://example.org 
    

    Il faut exécuter la commande:

    % pip config unset download.index-url 
    

Import de modules

Un module peut être un autre fichier Python .py ou un fichier C/C++. Un module possède un namespace privé et ce namespace n’est pas directement accessible à l’extérieur du module. Un module peut importer un autre module.

Pour utiliser un module, il faut l’importer. Plusieurs solutions sont possibles:

  • import <nom du module>: le module est importé dans le namespace local toutefois tous les noms des objets ne sont pas accessibles à partir du namespace local. Pour accéder aux objets du module, il faut taper <nom du module>.<nom de l'objet>.

    Par exemple:

    import pandas
    data = pandas.DataFrame()
    
  • import <nom de l'objet> as <nom alias>: permet d’éviter d’utiliser le nom entier du module pour accéder à ses objets. Avec cette syntaxe, le module est importé dans le namespace local toutefois les objets ne sont accessibles qu’en utilisant l’alias du module: <nom alias>.<nom de l'objet>.

    Par exemple:

    import pandas as pd
    data = pd.DataFrame()
    
  • from <nom du module> import <nom de l'objet>: on ne charge qu’un seul objet du module dans le namespace local. Cet objet est accessible en utilisant directement son nom.

    Par exemple:

    from pandas import DataFrame
    data = DataFrame()
    
  • from <nom du module> import *: tous les noms des objets du module sont importés dans le namespace local. Il n’est pas recommandé d’utiliser cette syntaxe car il peut y avoir des collisions entre des modules qui utiliseraient les mêmes noms d’objet. Avec cette syntaxe, les objets sont accessibles directement par leur nom.

    Par exemple:

    from pandas import *
    data = DataFrame()
    
  • from <nom du module> import <nom de l'objet> alias <alias de l'objet>: cette syntaxe permet d’importer le nom d’un objet du module et de permettre d’utiliser cet objet en utilisant un alias.

    Par exemple:

    from pandas import DataFrame as PandasDataframe
    data = PandasDataframe()
    

__file__

Quand un module est chargé à partir d’un fichier, on peut voir le chemin de ce fichier en utilisant la variable: <module ou alias>.__file__.

Par exemple:

  • Si on importe le module de cette façon: import numpy
    On peut voir le chemin du fichier utlisé pour l’initialisation avec:

    numpy.__file__
    
  • Si on importe le module en utilisant un alias: import numpy as npy
    Alors: npy.__file__ affichera le chemin du fichier d’initialisation.

Avoir des informations sur un package installé

On peut obtenir des informations concernant un package installés comme son emplacement en utilisant importlib.

Par exemple, pour rechercher des informations concernant numpy:

>>> import importlib
>>> importlib.util.find_spec('numpy')
ModuleSpec(name='numpy', loader=<_frozen_importlib_external.SourceFileLoader object at 0xffffa3cbdb80>, origin='/home/parallels/Documents/PYTHON/PIP_TESTS/VENV/venv/lib/python3.9/site-packages/numpy/__init__.py', submodule_search_locations=['/home/parallels/Documents/PYTHON/PIP_TESTS/VENV/venv/lib/python3.9/site-packages/numpy'])

dir()

La fonction permet de lister les noms d’objets définis dans le namespace local. Cette fonction permet de lister les variables, les fonctions et les modules.

Par exemple:

  • dir(): sans argument, ce sont les noms de variables, fonctions et modules qui sont accessibles dans le namespace local qui sont listés.
  • dir(<nom du module>): liste les objets accessibles dans le module.

Package wheel .whl

Un package wheel .whl est un fichier zip contenant les fichiers .py ou les bibliothèques nécessaires pour utiliser la dépendance. A l’installation, les répertoires dans le package sont décompressés dans le répertoire Python contenant les dépendances, par exemple dans le cas d’un environnement virtuel ce répertoire est du type:

<chemin du l'env. virtuel>/lib/site-packages 

Plus haut, on détaille les répertoires d’installation des packages suivant quelques cas de figure.

Généralement, les packages contiennent:

  • Un répertoire avec le nom du package contenant les fichiers Python .py
  • Un répertoire nommé <nom package>-<version>.dist-info contenant des informations concernant le package.

D’autres répertoires peuvent exister, en particulier si le package nécessite des dépendances dans des bibliothèques en C/C++.

Construire un package wheel

Il existe 2 méthodes pour construire des packages (cf. Build System Interface): avec un fichier .toml ou avec un fichier setup.py. Dans cet article, on explicitera seulement la méthode avec setup.py.

Pour construire un package wheel, il faut que le projet comporte à minima certains fichiers comme:

  • setup.py pour exécuter du code à l’installation et fournir les informations concernant le projet.
  • éventuellement un fichier __init__.py pour que le projet soit importable après installation.

Si on prend le même exemple de projet que celui présenté précédemment. Ce projet comprend les fichiers suivants:

python_package_example 
├── LICENSE 
├── PeopleCounter 
│   ├── Counter.py 
│   ├── __init__.py 
│   └── ScientistRepository.py 
├── README.md 
└── setup.py 

Pour utiliser le fichier setup.py, il faut installer le package setuptools en exécutant:

pip install setuptools

Le fichier setup.py doit, au minimum, comporter des informations relatives au projet, par exemple:

from setuptools import setup

setup(name='PeopleCounter', 
      version='1.0', 
      description='Python package example', 
      author='MM', 
      author_email='', 
      packages=['PeopleCounter'], 
      url="https://github.com/msoft/python_package_example", 
      license='MIT', 
      python_requires='>=3.8', 
      install_requires=[ 'numpy' ] 
     ) 

Les éléments importants sont:

  • name: le nom du projet
  • version: il faut en particulier utiliser les conventions indiquées précédemment
  • packages: chaînes de caractères indiquant le package qui sera manipulé par setuptools.
  • install_requires: indiquant les dépendances du package à construire.

Au minimum, il faut que les éléments name, version et packages soient indiqués. On peut trouver une liste plus exhaustive d’éléments qu’il est possible de préciser dans le fichier setup.py sur la page suivante: setuptools.pypa.io/en/latest/references/keywords.html.

On peut trouver un autre exemple simple de projet sur: github.com/pypa/sampleproject.

La documentation de setuptools se trouve sur la page suivante: setuptools.pypa.io/en/latest/userguide/index.html.

Dans le fichier setup.py, on peut rajouter des classificateurs (i.e. classifiers). Ce sont des indications utilisées pour la documentation. Ces indications peuvent servir si le package est uploadé dans un repository comme PyPI. Dans le cas de PyPI, on peut trouver une liste des classificateurs sur la page: pypi.org/pypi?%3Aaction=list_classifiers.

Une fois que le fichier setup.py est créé, on peut tester son installation/désinstallation en exécutant:

  • Installer le projet avec pip en utilisant:
    % pip install .  
    
  • Désinstaller en exécutant:
    % pip uninstall peoplecounter 
    

Les commandes précédentes permettent d’effectuer l’installation et la désinstallation à partir des fichiers source du projet juste dans le cadre d’un test.

Pour construire le package de distribution wheel à proprement parler, il faut exécuter dans le répertoire du projet:

% python setup.py bdist_wheel  

On peut trouver une documentation plus complète sur wheel sur wheel.readthedocs.io/en/stable.

Quelques détails sur les packages wheel

bdist_wheel permet de construire des packages wheel .whl mais il est possible de générer d’autres types de packages:

  • Ancien package .egg avec sdist: python setup.py sdist
  • Des packages RPM (RedHat Package Manager) avec bdist_rpm:
    • python setup.py bdist_rpm ou
    • python setup.py bdist --format=rpm
  • Des archives .tar.gz ou .zip suivant la plateforme avec bdist_dumb:
    python setup.py bdist_dumb
    python setup.py bdist --format=gztar
    python setup.py bdist --format=zip

Au lieu de définir des données concernant le package dans le fichier setup.py, il est possible d’utiliser un fichier de configuration setup.cfg. Ce fichier doit être placé dans le même répertoire que setup.py. Il existe des équivalences entre les paramètres indiqués dans le fichier setup.py et setup.cfg. Pour trouver une liste exhaustive des paramètres utilisables dans le fichier setup.cfg, on peut se reporter sur la documentation de setuptools.

Dans le cas où on utilise bdist_wheel pour générer un package wheel, il sera généré dans le répertoire:

<répertoire du projet>/dist 

Le nom du fichier généré est de type:

<nom package>-<version>-<tag python>-<tag ABI>-<tag platform>.whl 

avec:

  • Tag python correspondant à:
    • py pour la version générique de Python
    • py3 pour Python 3
    • cp pour CPython
    • ip pour IronPython
    • pp pour PyPy
  • Tag ABI:
    ABI correspond à Application Binary Interface. A chaque version de Python, l’API C de Python est modifiée. La majorité de ces changements ne modifient pas la compatibilité du code source toutefois ils peuvent casser la compatibilité binaire. L’interface binaire de Python est donc identifiée car elle change pour chaque version. Un package peut être dépendant de cette interface. Si c’est le cas, le tag ABI permet d’indiquer l’interface avec laquelle le package est compatible. S’il n’y a pas de dépendances avec l’ABI, le tag ABI est "none".

    Dans le cas d’une dépendance, la version de CPython est indiquée, par exemple:

    • cp33 pour CPython 3.3.
    • cp33d pour la version debug de Python 3.3.

    On peut limiter à une ABI particulière un package en utilisant le paramètre --py-limited-api à la construction du package.

  • Tag platform: permet d’indiquer la plate-forme avec laquelle le package est compatible. Sans indication particulière, l’indication est ”any”. Par exemple, la plate-forme peut être:
    • win32 pour Windows 32-bits.
    • linux_i386 pour un set d’instructions compatible i386.
    • linux_x86_64 pour la version 64 bits du set d’instructions x86.
    • aarch64 pour les instructions ARM 64 bits.

    On peut limiter à une plate-forme particulière un package en utilisant le paramètre --plat-name à la construction du package:

    % python setup.py bdist_wheel --plat-name linux_x86_64 
    

Pour installer le package, il suffit d’exécuter:

pip install <chemin du fichier .whl>  

CPython

On a coutume de dire que Python est un langage interprété. C’est vrai toutefois il n’est pas interprété au même titre que Javascript par exemple. L’implémentation originale de Python est CPython. CPython est à la fois un interpréteur et un compilateur implémenté en C.

Lors de l’exécution de code Python, CPython effectue les étapes suivantes:

  • Initialisation de CPython: cette étape permet d’initialiser les structures de données nécessaire pour exécuter Python, préparer les types de bases du langage, configurer et charger les modules de base.
  • Compilation du code source: le code source est transformé en bytecode. Des optimisations peuvent être appliquées sur le bytecode généré.
  • Interprétation du bytecode: du fait que le code généré n’est pas du code machine, on considère que CPython interprète le code Python sous forme de bytecode.

Il existe d’autres implémentations de Python fonctionnant différemment comme:

  • Jython implémenté en Java et convertissant le code Python en Java bytecode.
  • IronPython implémenté en C# et convertissant le code Python en bytecode interprétable par le CLR (MSIL).
  • PyPy: autre implémentation de Python permettant d’utiliser un compilateur JIT (Just-In-Time).

L’architecture de CPython faite en couches, les différents niveaux de ces couches pourraient être:

  1. Runtime: correspond à l’état global du processsus, il inclut le GIL (cf. Global Interpreter Lock et le mécanisme d’allocation de la mémoire. Le GIL est un mutex au niveau du processus autorisant l’exécution d’un seul thread permettant de contrôler l’interpréteur Python.
  2. L’interpréteur: groupe de threads ainsi que les données qu’ils partagent comme les modules importés.
  3. Thread: données relatives à un seul thread contenant la pile d’exécution (cf. call stack).
  4. Frame (cf. execution frame ou structure de la pile): correspond à un élément de la pile d’exécution (cf. call stack). Une frame contient l’objet du code (cf. object code) qui est le résultat de la compilation et elle fournit un état pour exécuter cet objet.
  5. Boucle d’évaluation: permet d’exécuter les objets d’une frame.

Si on considère le code suivant dans un fichier test.py:

def print_hello_world(): 
    print('Hello world') 

if __name__ == 'main': 
    print_hello_world() 

Si on compile ce code en exécutant:

% python -m py_compile test.py 

On obtient un fichier test.cpython-36.pyc dans le répertoire __pycache__.

Si on décompile le fichier en exécutant le code suivant:

import platform 
import time 
import sys 
import binascii 
import marshal 
import dis 
import struct 

def view_pyc_file(path): 
    """Read and display a content of the Python`s bytecode in a pyc-file.""" 

    file = open(path, 'rb') 
    magic = file.read(4) 
    timestamp = file.read(4) 
    size = None 

    if sys.version_info.major == 3 and sys.version_info.minor >= 3: 
        size = file.read(4) 
        size = struct.unpack('I', size)[0] 

    print(file) 
    code = marshal.load(file) 

    magic = binascii.hexlify(magic).decode('utf-8') 
    timestamp = time.asctime(time.localtime(struct.unpack('I', timestamp)[0])) 

    dis.disassemble(code) 
    print('-' * 80) 

    print('Python version: {}\nMagic code: {}\nTimestamp: {}\nSize: {}' 
        .format(platform.python_version(), magic, timestamp, size) 
    ) 

    file.close()  


if __name__ == '__main__': 
    print(sys.argv[1]) 
    view_pyc_file(sys.argv[1]) 

Source du code: https://stackoverflow.com/questions/11141387/given-a-python-pyc-file-is-there-a-tool-that-let-me-view-the-bytecode

On obtient:

% python view_pyc_file.py __pycache__/test.cpython-39.pyc:  
   2            0 LOAD_CONST             0 (<code object print_hello_world at 0x000000145877D5030, file "test.py", line 2>) 
                2 LOAD_CONST.            1 ('print_hello_world) 
                4 MAKE_FUNCTION          0 
                6 STORE_NAME             0 (print_hello_world) 
   5            8 LOAD_NAME              1 (__name__) 
                10 LOAD_CONST            2 ('__main__') 
                12 COMPARE_OP            2 (==)  
                14 POP_JUMP_IF_FALSE 22 
                16 LOAD_NAME             0 (print_hello_world) 
                18 CALL_FUnCTION         0           
                20 POP_TOP 
   >>           22 LOAD_CONST            3 (None) 
                24 RETURN_VALUE 

L’en-tête du fichier bytecode contient:

  • Un nombre sur 4 octets: magic number. Ce nombre est composé de 2 premiers octets qui changent pour chaque changement dans le code marshallé (typiquement ce nombre peut changer d’une version à l’autre de python). Les 2 octets suivant sont des caractères de retour à la ligne (carriage return et line feed). Le but du magic number est d’éviter la corruption du fichier .pyc par copie. Si ce fichier est lu comme un fichier texte, le retour à la ligne va corrompre le magic number.
  • Une indication de date sur 4 octets (timestamp): ce timestamp indique l’heure Unix de génération du fichier.
  • Le reste de fichier contient l’objet code marshallé (code objet) correspondant au bytecode généré.

Dans le code affiché:

  • Les numéros à gauche 2, 5, 6 correspondent au numéros de ligne dans le code source.
  • Les numéros suivant vers la droite 0, 2, 4, 6 correspondent au décalage par rapport au début du fichier source.
  • La colonne suivante contient les instructions.
  • L’argument des instructions sous la forme d’un entier.
  • Le caractère >> correspond au point atteint après un saut d’instruction. Par exemple l’instruction POP_JUMP_IF_FALSE renvoie à l’instruction 22.

Pour résumer

Installer des packages

pip install <noms des packages>

Par exemple: pip install numpy

  • Installation à partir d’un fichier .zip ou .tar.gz:
    pip install -f <chemin de l'archive>
  • Installation avec une condition de version:
    pip install <noms des packages>==<version>

    Par exemple: pip install numpy==1.19

  • Installation en mode éditable:
    pip install -e <chemin du répertoire contenant le setup.py>
  • Installation à partir d’un repository GitHub:
    pip install git+<url repo. .git>

    Par exemple: pip install git+https://github.com/msoft/python_package_example.git

  • Installation à partir d’un fichier requirements.txt:
    pip install -r <chemin du fichier requirements.txt>
  • Installation d’une version en pré-release:
    pip install --pre <nom du package>

Mettre à jour un package

pip install -U <nom du package>

Pour mettre à jour pip:

python -m pip install -U pip

Télécharger les packages .whl sans les installer

pip download <noms des packages>

Mêmes options que pour pip install.

Désinstaller des packages

pip uninstall <noms des packages>

Lister les packages installés

pip list

Lister les packages obsolètes:

pip list -o

Chercher un package

pip search <nom du package>

Lister les packages avec leur version

pip freeze

Sauvegarder la liste des packages dans un fichier requirements.txt:

pip freeze > requirements.txt

Afficher des informations concernant un package

pip show <nom du package>

Afficher le répertoire contenant le cache

pip cache dir

Import de modules

Les imports de module peuvent se faire de ces façons:

Syntaxe import Objets du module Exemple
import <nom du module> <nom du module>.<nom de l'objet> import pandas
data = pandas.DataFrame()
import <nom de l'objet> as <nom alias> <nom alias>.<nom de l'objet> import pandas as pd
data = pd.DataFrame()
from <nom du module> import <nom de l'objet> Directement avec le nom from pandas import DataFrame
data = DataFrame()
from <nom du module> import * Directement avec le nom from pandas import *
data = DataFrame()
from <nom du module> import <nom de l'objet> alias <alias de l'objet> Avec l’alias from pandas import DataFrame as PandasDataframe
data = PandasDataframe()
Références

Syntaxe Python de base


Le but de cet article est de présenter de façon succincte les éléments de base de la syntaxe Python. Pour un développeur C#, l’apprentissage de Python peut paraître aisé car la plupart des mots clé sont identiques toutefois comme souvent il faut éviter de penser par analogie. Python est un langage particulier avec ses caractéristiques qui peuvent être spécifiques par moment.

Ce premier article passe en revue les éléments de syntaxe de base en indiquant les différences marquantes avec un langage comme C#. D’autres articles permettront d’aborder d’autres aspects spécifiques de l’environnement Python.

Des indications sont apportées lorsque des éléments de syntaxe sont très différents des autres langages.

Sommaire

Python en quelques mots
Versions
Syntaxe positionnelle

Variable et typage
  type()
  Mutable vs immutable
Types courants
  Booléens
  None
  Entier
  Flottant
  Chaîne de caractères
  Bytes
Portée des variables
  global
  globals() et locals()
  id()

Les conditions
if…then…else
  elif
  Version condensée
  bool()
Opérateurs booléens

Les collections
Liste
  list()
  Index
  Affectation de plusieurs éléments (list slicing)
  Passage par référence
  Ajouter des éléments
  Supprimer un élément
  Effectuer une copie d’une liste
  len()
  count()
  Concaténer des listes
  Répéter le contenu d’une liste (avec *)
  in
  Liste de listes
  Inverser l’ordre des éléments
  Ordonner les éléments de la liste
  Déconstruction
Tuples
  tuple()
  len()
  count()
  Concaténation d’un tuple (avec +)
  Répéter le contenu d’un tuple (avec *)
  in/not in
  Tuple de tuples
  zip()
  Déconstruction
Dictionnaire
  Modifier une valeur
  update()
  Supprimer une clé/valeur
  get()
  dict()
  keys()
  values()
  Parcourir les valeurs d’un dictionnaire
  copy()
  Dictionnaires imbriqués
  in/not in
set
  set()
  add()
  update()
  Supprimer un élément d’un set
  Effectuer une copie d’un set
  in/not in
  Opérations applicables sur les sets
Itérable

Fonctions
Arguments
  Paramètre par défaut
  Préciser le nom des arguments
  Nombre variable d’arguments
  Arguments variables indiqués sous forme d’un dictionnaire
Fonctions imbriquées
Fonctions de premier ordre
Quelques fonctions particulières
  map()
  filter()
  reduce()
Fonction lambda

Boucles
for
  range()
while
break et continue
Enumérateur
Comprehensions
  List comprehension
  Sets comprehension
  Dictionary comprehension
Generators
  Fonctions generator avec état
  Generator comprehension (ou generator expression)

Exceptions
Gestion de plusieurs types d’erreurs
Prendre en compte tous les types d’exceptions
  Pour afficher l’erreur
Relancer une exception
  Lancer une exception
finally
else

Classe
Instancier une classe
Méthode membre
Initializer
Attributs de classe et d’instance
Définir une variable statique
Héritage et polymorphisme
  Dériver d’une classe
  Surcharger une fonction
  Héritage multiple

Lecture et écriture de fichiers
Ecrire un fichier
Lire un fichier
  Utiliser des iterators
Ecrire à la suite d’un fichier texte
Ecrire un fichier binaire
  Utiliser un bloc try…finally
  Considérer un contexte de lecture avec des “with blocks”

Python en quelques mots

Python est un langage interprété multiplateforme libre permettant la programmation de haut niveau impérative, fonctionnelle et orientée objet. La gestion de la mémoire est automatique. Une caractéristique importante de ce langage est que les éléments techniques de programmation et de syntaxe sont simplifiés pour faciliter son implémentation. D’autre part, il dispose d’une grande richesse de bibliothèques techniques et scientifiques. La syntaxe est positionnelle c’est-à-dire qu’il n’y a pas d’accolades. Enfin, l’implémentation dans ce langage est extensible en C.

Parmi ses défauts, on peut citer sa lenteur par rapport à des langages compilés. Bien-que le typage est fort, il est moins stricte car dynamique. Ensuite, il n’y a pas de pointeurs, il n’est donc pas possible d’effectuer des manipulations de la mémoire. Enfin, le code ne permet pas d’effectuer de l’encapsulation.

Versions

Date Python 2 Python 3
Octobre 2000 2.0
  • Prise en charge des chaînes de caractères Unicode
  • List comprehension
  • Algorithme de Garbage Collection se basant sur des cycles plutôt que sur un compteur.
Avril 2001 2.1 Portée imbriquées des variables.
Décembre 2001 2.2
  • Unification de la hiérarchie orientée objet des types et classes.
  • Ajout des generators.
Juillet 2003 2.3
Novembre 2004 2.4
  • Ajout generator expression (generator comprehension)
  • Décorateur de fonction
  • Type decimal
Septembre 2006 2.5 Ajout de with
Octobre 2008 2.6 Ajout des fonctionnalités de la 3.0 dont typeError, bin(), _complex_()
Décembre 2008 3.0
  • Déplacement de la fonction reduce() dans functools.
  • Modification des exceptions avec l’utilisation du mot-clé as.
  • Ajout de with.
  • Amélioration de la syntaxe pour la fonction print().
  • raw_input a été renommé en input.
  • Les chaînes de caractères sont en Unicode.
  • La division renvoie un float plutôt qu’un entier. Il faut utiliser // pour avoir un entier.
Juin 2009 3.1 L’ordre de parcours des dictionnaires est conservé.
Juillet 2010 2.7 Bug fix
Février 2011 3.2 Ajout du module argparse
et futures.
Septembre 2012 3.3
  • Ajout de yield from.
  • Ajout de la possibilité de déclarer une chaîne unicode pour faciliter la transition python 2 vers python 3.
  • Il n’est plus nécessaire d’indiquer le type précis d’une exception pour qu’elle soit attrapée avec try...except, on peut utiliser des erreurs plus génériques comme OSError.
Mars 2014 3.4 Ajout du module asyncio.
Septembre 2015 3.5 Support de l’implémentation asynchrone avec des objets awaitables, coroutine, itération asynchrone, gestionnaire de contexte asynchrone.
Décembre 2016 3.6
  • Support des generators asynchrones, comprehensions asynchrones.
  • Ajout des f-strings.
Juin 2018 3.7
  • Ajout des mots clés async/await.
  • Evaluation des annotations durant l’exécution.
  • Ajout de la fonction breakpoint().
Octobre 2019 3.8
  • Ajout de l’opérateur := (walrus operator) permettant d’assigner une variable dans une expression.
  • Arguments de fonction positionels seulement.
  • Ajout de l’opérateur dans les f-strings pour représenter une expression et le résultat de l’évaluation de cette expression.
Octobre 2020 3.9
  • Ajout des opérateur | pour merger 2 dictionnaires et |= pour merger 2 dictionnaires et mettre à jour un des dictionnaires.
  • Ajout des fonctions str.removeprefix() et str.removesuffix() pour supprimer certaines parties d’une chaîne de caractères.
Octobre 2021 3.10
  • Amélioration de la recherche d’erreurs.
  • Pattern matching structurel avec switch...case.
  • Opérateur | pour indiquer l’union de 2 types dans la définition d’arguments de fonctions.
  • Ajout des fonctions aiter() et anext() pour des itérations asynchrones.

Syntaxe positionnelle

Il n’y a pas d’accolades ni de points virgules pour délimiter les instructions. En revanche, les espaces et retours à la lignes sont significatifs:

  • L’indentation permet de délimiter les blocs de code. Généralement, 4 espaces sont utilisés.
  • Il ne faut pas mélanger les tabulations et les espaces.

Par exemple:

for i in range(10):
    # 4 espaces pour indiquer un bloc
    if i % 2 == 0:
        # 4 espaces de plus pour indiquer un autre bloc
        print('Pair %d' % i)
    else:
        print('Impair %d' % i)

Il est conseillé d’utiliser:

  • 1 saut de ligne pour délimiter du code dans un même bloc et
  • 2 sauts de lignes pour différencier des blocs différents: par exemple entre 2 fonctions et 2 classes etc…

Ces préconisations ne sont pas obligatoires mais fortement conseillées. Ne pas les suivre peut entraîner des warnings de certains IDE.

Variable et typage

En Python, le typage des variables est dynamique et fort, cela signifie que:

  • Une affectation permet de déclarer, d’initialiser une variable et de typer une variable: la valeur d’initialisation permet d’indiquer le type, il n’y a pas de mot clé pour indiquer le type.
  • On peut changer le type de certaines variables en effectuant une nouvelle affectation. Suivant le type initial, le changer par une nouvelle affectation n’est pas tout le temps possible.
  • Une variable typée a des caractéristiques spécifiques à son type. Une erreur est levée si des opérations non conformes à ce type sont effectuées.
  • Une erreur est levée si une variable est utilisée sans être initialisée.

Par exemple:

>>> a = 10 # la variable a est déclarée et initialisée en tant qu'entier.
>>> b = a + '10'  # ERREUR car '10' est une chaine de caractères.

>>> print(c)      # ERREUR car c n'a pas été initialisée
>>> a = 'chaine'  # OK a est désormais une chaine de caractères.

Il n’existe pas de mot clé comme var ou let pour indiquer qu’on déclare une variable, seule l’initialisation permet la déclaration d’une variable locale en dehors des arguments d’une fonction.

type()

Cette fonction retourne le type d’une variable, par exemple:

>>> a = 'Ceci est une chaine'
>>> print(type(a))
<class 'str'>

>>> a = 5
>>> print(type(a))
<class 'int'>

>>> a = 5.0
>>> print(type(a))
<class 'float'>

Mutable vs immutable

Suivant son type, il sera possible de modifier ou non la valeur d’une variable:

  • Mutable: on peut modifier la valeur d’une variable,
  • Immutable: après initialisation, toute modification de la valeur d’une variable ne sera pas possible sans effectuer une nouvelle affectation.

Par exemple, une chaîne de caractères est immutable:

a = 'ABCDEF'
a[1] = 'Z'    # ERREUR: on ne peut pas modifier une chaîne de caractères
a = 'FEDCBA'  # OK nouvelle affectation = nouvelle instance

On indiquera par la suite si le type est mutable ou immutable.
Parmi les types de base, tous les types sont immutables sauf les collections. Les collections sont mutables à l’exception des tuples qui sont immutables.

Types courants

On va passer en revue les types courants et leurs caractéristiques:

Booléens

Ce type est immutable, les valeurs possibles sont True ou False.

Les opérateurs logiques sont: and, or et not:

>>> a = True
>>> b = False
>>> print(a and b)
False

>>> print(a and not b)
True

bool()

Cette fonction renvoie un booléen correspondant à la valeur en argument. Contrairement à ce qu’on pourrait croire, cette fonction n’effectue pas de cast, le booléen en retour dépend du type de l’argument et de sa valeur (voir Truthy vs Falsy).

>>> a = 'True'  # Ceci est une chaîne de caractères
>>> b = bool(a)
>>> print(a)
True

>>> print(type(b))
<class 'bool'>
Truthy vs Falsy

Le comportement de bool() n’est pas forcément celui auquel on s’attend. Cette fonction n’effectue pas un cast, elle évalue l’objet fourni en argument pour renvoyer un booléen.

Par exemple:

>>> print(bool('True'))
True

>>> print(bool('False'))
True

bool() renvoie vrai car la chaîne de caractère est non vide.

Ainsi, certaines valeurs peuvent être:

  • Falsy quand une évaluation avec bool() renvoie False et
  • Truthy quand une évaluation avec bool() renvoie True.

Les valeurs Falsy sont:

  • Collections:
    • Structure vide (liste, tuple, dictionnaire, set)
    • Chaine de caractères vide
    • range(0)
  • Nombres: nombre égal à 0
    • entier: 0
    • flottant: 0.0
    • Nombre complexe: 0j
  • Constantes:
    • None
    • False

Les valeurs Truthy sont:

  • Liste non vide
  • Nombre différent de 0
  • True

Par exemple:

bool(0) == False
bool(0.0) == False
bool(0.2) == True
bool([]) == False        # car la liste est vide
bool([5, 9, 6]) == True  # car la liste est non vide
bool("") == False        # car la chaîne est vide
bool("Span") == True     # car la chaîne est non vide
bool("True") == bool("False") == True  # car non vide

Ne pas utiliser bool() pour déterminer si une variable est initialisée.

Si on utilise bool() pour déterminer si une variable est initilisée, il se produira une erreur car la variable n’a pas été déclarée:

if bool(unknown_value):
    print('OK')
else:
    print('KO')

ERREUR: NameError: name 'unknown_value' is not defined

Il n’y a pas de moyen simple de voir si une variable est initialisée, il faut l’entourer d’un try...except:

try:
    if unknown_value:
        print('OK')
    else:
        print('KO')
    except NameError:
        print('KO')

None

None est une constante qui pourrait correspondre à null dans les autres langages. Il s’agit d’un type et d’une valeur, on peut utiliser == ou is pour comparer une variable à None:

>>> a = None # affectation de la valeur None
>>> print(a)
None

>>> print(type(a))
<class 'NoneType'>

>>> print(a == None)
True

>>> print(a is None)
True
Une variable non initialisée n’a pas pour valeur None

Même si None est un équivalent de null pour d’autres langages, une variable non initialisée n’a pas pour valeur None. Comme indiqué précédement, une variable non initialisée en Python n’est pas non plus déclarée. Si a n’a pas été initialisée, la ligne suivante mène à une erreur:

if a == None:
    print(OK)  # ERREUR car 'a' n'a pas été initialisée

Entier

Un entier est immutable. Contrairement aux autres langages, Python utilise un nombre de bits pour stocker des entiers. La taille de l’entier à stocker n’est pas donc limitée à la taille du type entier.

Par exemple:

from sys import getsizeof

a = 223423435364675675675676575675674324234234234234242343
print(getsizeof(a))  # 48 bytes

Pour affecter un entier sous forme décimal, il ne faut pas utiliser de point '.':

>>> a = 10
>>> print(type(a))

<class 'int'>

On peut affecter des entiers sous des formes différentes, par exemple:

  • binaire: 0b10
  • octodecimal: 0o10
  • Hexadecimal: 0x10

Par exemple:

>>> a = 0b10
>>> print(a)
2

>>> a = 0o10
>>> print(a)
8

>>> a = 0x10
>>> print(a)
16

bin(), oct() et hex()

On peut utiliser les fonctions bin(), oct(), hex() pour convertir respectivement en entier binaire, octodecimal ou hexadecimal, par exemple:

>>> a = bin(512)
>>> print(a)
0b1000000000

>>> a = oct(512)
>>> print(a)
0o1000

>>> a = hex(512)
>>> print(a)
0x200

int()

La fonction int() peut être utilisée pour convertir un objet en entier décimal quand cela est possible. L’objet à convertir peut être une chaîne de caractères ou un entier dans une base différente, par exemple:

>>> a = int('657')
>>> print(a)
657

>>> print(type(a))
<class 'int'>

>>> b = int(0x2ED0)
>>> print(b)
11984

Si la conversion n’est pas possible, une erreur de type ValueError est renvoyée:

>>> int('dfgdfg')
ValueError: invalid literal for int() with base 10: 'dfgdfg'

Flottant

Les flottants sont immutables. Ce type permet de stocker les nombres flottants. Ils doivent être initialisés avec le caractère '.':

>>> a = 43.45
>>> print(a)
43.45

>>> print(type(a))
<class 'float'>

>>> b = 4.0

On peut utiliser la notation avec l’exposant:

>>> c = 1e+6
>>> print(c)
1000000.0

>>> d = 1e-3
>>> print(d)
0.001

float()

Cette fonction permet d’effectuer des conversions en nombre flottant quand cela est possible. L’objet à convertir peut être une chaîne de caractères:

>>> a = float('1.5e+4')
>>> print(a)
15000.0

>>> b = float('6565.989')
>>> print(b)
6565.989

>>> c = float('65,826')
>>> print(c)
ERREUR: ValueError: could not convert string to float: '65,826'

Opérateurs

On peut utiliser les opérateurs suivants:

  • +, -, /, * pour respectivement l’addition, soustraction, division et multiplication.
  • // division entière
  • ** puissance
  • % reste de la division
  • ? ET bit à bit
  • | OU bit à bit
  • ^ OU exclusif bit à bit

Conversion implicite

Python permet d’effectuer des conversions implicites pour des variables de type nombre comme float et integer.

Par exemple:

>>> a = 5
>>> print(type(a))
<class 'int'>

>>> b = a + 1.3
>>> print(b)    # conversion implicite: b est un flottant
6.3

>>> print(type(b))
<class 'float'>

>>> c = a + '1.3'  # conversion implicite non possible
ERREUR: TypeError: unsupported operand type(s) for +: 'int' and 'str'

>>> c = a + float('1.3')    # conversion explicite
>>> print(type(b))
<class 'float'>

>>> a = 7
>>> print(type(a))
<class 'int'>

>>> d = a/2
>>> print(d)
3.5        # A partir de Python 3, le résultat est de type float

>>> print(type(d))   # conversion implicite
<class 'float'>

‘nan’ et ‘inf’

'nan' (pour Not A Number) et 'inf' (pour infini) sont des flottants constants accessibles sous forme des chaînes de caractères. Par exemple:

>>> a = float('inf')
>>> print(a)
inf

>>> print(type(a))
<class 'float'>

>>> b = a /2
>>> print(b)
inf

>>> c = a + 6
>>> print(c)
inf

>>> d = inf
ERREUR: NameError: name 'inf' is not defined

>>> e = float('nan')
>>> print(e)
nan

>>> print(type(e))
<class 'float'>

>>> f = e /2
>>> print(f)
nan

>>> g = nan
ERREUR: NameError: name 'nan' is not defined

format()

La fonction format() avec une chaine de caractères permet de formater les nombres d’une certaine façon. Voir cette fonction dans le cadre des chaines de caractères.

Chaîne de caractères

Une chaîne de caractères est immutable. Il n’existe pas caractères en Python, un caractère est stocké sous forme d’une chaîne de caractères.

Les chaines sont en Unicode (UTF-8).

Ainsi:

>>> a = 'ABCDEF'
>>> a[2] = 'A'
ERREUR TypeError: 'str' object does not support item assignment
# Car les chaînes de caractères sont immutables

Quand on utilise a[2], on obtient le 3e caractère de la chaine mais le résultat est de type string.

Pour définir des chaînes de caractères, on peut utiliser '...' ou "...". Si on doit effectuer des commentaires sur plusieurs lignes, il faut utiliser """...""":

>>> a = 'ABCDEF'
>>> b = "ABCDEF"
>>> c = """ABCDEF"""
>>> print(a == b)
True

>>> print(a == c)
True

>>> d = """Une
chaîne
sur
plusieurs
lignes""" 

>>> print(d)

Quand on doit inclure un caractère quote ' dans une chaîne, on peut utiliser "..." pour délimiter la chaîne:

example = "This's is a string with a quote"

Inversement si on doit inclure des caractères " dans une chaîne, on peut utiliser ' pour délimiter la chaîne:

example = '"Yes" or "No"'

On peut aussi utiliser la caractère d’échappement \, par exemple:

'This is a \' character'     # ' est échappé
"This is a \" character"     # " est échappé
"This is a \\ character"     # \ est échappé

Enfin on peut déclarer la chaîne avec le préfixe r pour raw (voir plus bas).

Le type char n’existe pas en Python

Un caractère est indiqué sous la forme d’une chaîne de caractères contenant un seul caractères.

city = "Oslo"

city[2] est une chaîne de caractères.

Chaînes de caractères sur plusieurs lignes

Généralement """ est utilisé pour les commentaires de fonctions. On peut utiliser """ pour des commentaires sur plusieurs lignes. Si la chaine n’est pas utilisée pour effectuer une affectation alors elle sera considérée comme un commentaire.

Préfixes pour les chaines de caractères

  • u – unicode: par défaut les chaines de caractères en Python sont en UTF-8. Ce préfixe n’est pas nécessaire toutefois il existe pour apporter une compatibilité avec Python 2.
  • b – byte: les variables initialisées de cette façon b'...' semblent être des chaines de caractères toutefois ce n’est pas le cas. Il s’agit d’un tableau de bytes (octet) dont chaque caractère ASCII correspond à un entier codé entre 0 et 255.

    Par exemple:

    a = b'Not a string'
    

    a[4] retourne 97. a[4] correspond au caractère 'a' dont l’encodage ASCII est 97.

    Si on tente d’utiliser un caractère ne faisant pas partie de l’encodage ASCII, on obtient une erreur:

    a = b'Not a string àé'
    

    On obtient une erreur:

    SyntaxError: bytes can only contain ASCII literal characters.
    
  • r – raw: ce préfixe est utilisé pour indiquer que la chaine de caractères doit être traitée de façon brute.

    Par exemple:
    Le caractère \n est interprété comme un retour à la ligne:

    >>> print('Retour\nà\nla\nligne')
    Retour
    à
    la
    ligne
    

    Si on utilise le préfixe r, \n n’est pas interprété comme un retour à la ligne:

    >>> print(r'Retour\nà\nla\nligne')
    Retour\nà\nla\nligne
    

    L’utilisation du caractère d’échappement \ produit le même résultat:

    >>> print('Retour\\nà\\nla\\nligne')
    Retour\nà\nla\nligne
    
  • f – formatting: permet de formater les chaines en exécutant ce qui se trouve entre les caractères { }, par exemple:
    >>> a = 4
    >>> b = 'quatre'
    >>> c = f'Le chiffre {a} en lettres est {b}'
    

    On obtient:

    Le chiffre 4 en lettres est quatre
    

On peut combiner les préfixes et ils ne sont pas sensibles à la casse.

str()

Cette fonction permet de convertir en chaîne de caractères des objets ayant un autre type, par exemple:

>>> a = str(6.02)
>>> b = str('466')
>>> print(a)
6.02

>>> print(type(a))
<class 'str'>

>>> print(b)
466

>>> print(type(b))
<class 'str'>

len()

Cette fonction permet de retourner la longueur d’une chaîne de caractères:

>>> a = 'Example string'
>>> len(a)
14

join()

Concaténer des chaines de caractères avec un caractère:

";".join(['str1', 'str2', 'str3'])

On obtient: ‘str1;str2;str3’

On peut aussi faire cette manip avec une chaine vide:

''.join(['str1', 'str2', 'str3'])

On obtient: 'str1str2str3'.

split()

colors.split(';')

On obtient ['str1', 'str2', 'str3'].

Partitionner des chaines

On peut partitionner une chaine en utilisant une autre chaine en tant que séparateur:

unforgetable = 'unforgetable'
unforgetable.partition("forget") 

"forget" est la chaine de séparation.

On obtient: ('un', 'forget', 'able')

On peut utiliser le caractère _ (ie. underscore) pour indiquer qu’une variable n’est pas utilisée:

origin, _, destination = "Seatle_Boston".partition('_')      

C’est une espèce de déconstruction.

On obtient:

origin == 'Seatle'
destination == 'Boston'

format()

La fonction format() permet de positionner des chaînes de caractères dans une autre chaîne en utilisant des arguments avec '{...}'.

Il existe plusieurs syntaxe pour cette fonction, certaines syntaxes sont anciennes et d’autres plus actuelles. Dans cette partie ne seront présentée que les fonctionnalités principales de cette fonction, pour avoir une liste exhaustive de ces fonctionnalités se reporter à la page https://pyformat.info/.

Une 1ère syntaxe permet de nommer les variables, par exemple:

>>> example = 'Ma position est: {latitude} {longitude}'
>>> print(example.format(latitude='60N', longitude='5E'))
Ma position est: 60N 5E

On peut placer une chaîne suivant son index dans la liste des arguments de la fonction format(), par exemple:

>>> example = 'Ma position est: {1} {0}'
>>> print(example.format('5E', '60N'))   # '5E' est à l'index 0; '60N' est à l'index 1
Ma position est: 60N 5E

Une autre syntaxe permet d’utiliser un motif pour indiquer l’emplacement de la chaîne à placer:

  • '%s' pour placer une chaîne de caractères
  • '%d' pour placer un entier
  • '%f' pour placer un flottant
  • '{}' permet de placer n’importe quel type d’objet

Pour plus de détails dans le cas des nombres:

  • Entiers:
    • Dans une chaine:
      • '%d' % (42,)42
      • '{:d}'.format(42)42
    • Padding:
      • '%4d' % (42,)' 42'
      • '{:4d}'.format(42)' 42'
      • '%04d' % (42,)'0042'
      • '{:04d}'.format(42) '0042'
    • Avec des nombres signés:
      • '%+d' % (42,)'+42'
      • '{:+d}'.format(42)'+42'
      • '% d' % ((- 23),)' -23'
      • '{: d}'.format((- 23))'-23'
      • '% d' % (42,)' 42'
      • '{: d}'.format(42)' 42'
  • Float:
    • Dans une chaine:
      • '%f' % (7.345353465345345,)7.345353
      • '{:f}'.format(7.345353465345345)7.345353
    • Padding:
      • '%06.2f' % (7.345353465345345,)007.34
      • '{:06.2f}'.format(7.345353465345345) 007.34
        6 chiffres significatifs et 2 chiffres après la virgule.

Formattage avec %

Mise à part format(), une autre syntaxe permet de positionner une chaine en utilisant %, par exemple:

>>> longitude = '60N'
>>> latitude = '5E'
>>> print('Ma position est: longitude= %s  latitude= %s' % (longitude, latitude))
Ma position est: longitude= 60N  latitude= 5E

Dans le cadre de cet exemple, (longitude, latitude) est un tuple fourni à la chaine de caractères avec l’opérateur %. Ainsi si le tuple contient 2 éléments alors la chaine de caractères doit contenir 2 fois %s.

Ainsi pour d’autres types:

  • Pour un entier:
    >>> data = 5
    >>> print("Elément affiché: %s" % data)
    "Elément affiché: 5"
    
  • Dans le cas d’une liste:
    >>> data = [1, 2, 3]
    >>> print("Elément affiché: %s" % data)
    "Elément affiché: [1, 2, 3]"
    
  • Dans le cas d’un tuple:
    >>> data = (1, 2 ,3)
    >>> print("Elément affiché: %s" % data)
    ERREUR
    

    Pour afficher le tuple il faut écrire:

    >>> print("Elément affiché: %s" % (data,))
    "Elément affiché: (1, 2, 3)"
    

    Par contre:

    >>> data = (1, 2 ,3)
    >>> print("Eléments affichés: %s, %s, %s" % data)
    "Eléments affichés: 1, 2, 3"    # OK
    

Concaténer des chaînes de caractères (avec +)

L’opérateur + avec des objets de type string permet de concaténer des chaînes:

>>> concatanated_string = "Une" + " " + "chaine"
>>> print(concatanated_string)
Une chaine

Dupliquer le contenu d’une chaîne (avec *)

L’opérateur * permet de dupliquer le contenu d’une chaîne de caractères.

Par exemple:

>>> print('ABC' * 3)
ABCABCABC

Indexation

On peut récupérer un caractère si on applique un index sur une chaîne de caractères:

>>> example = 'ABCDEFG'
>>> print(example[2])
C

Il est possible d’appliquer d’autres arguments dans l’index:

 <chaîne de caractères>[<index debut>:<index fin exclu>:<pas>]

Tous les arguments de l’index ne sont pas obligatoires:

  • Si l’index de début n’est pas indiqué (par exemple [:3]) alors on considère toute la chaîne jusqu’à l’index de fin exclu.
  • Si l’index de fin n’est pas indiqué (par exemple [3:]) alors on considère la chaîne à partir de l’index de début jusqu’à la fin.
  • L’argument correspondant au pas est facultatif.

Par exemple:

  • Dans cet exemple, il s’agit d’une chaîne de caractères même dans le cas d’un seul caractère:
    >>> example = 'ABCDEFGHIJKLMNOP'
    >>> example[0]
    A
    
  • On considère la chaine à partir du 5e caractère jusqu’au 7e (le 8e étant exclu):
    >>> example[5:8]
    'FGH'
    
  • On commence au caractère à l’index 1 jusqu’au 7e (le 8e étant exclu) avec un saut d’un caractère (2e caractère après le caractère courant):
    >>> example[1:8:2]
    'BDFH'
    
  • Index négatif, par exemple 1 caractère en partant de la fin:
    >>> example[-1]
    'P'  
    
  • 3e caractère en partant de la fin jusqu’à 1 caractère exclu:
    >>> example[-3:-1]
    'NO'
    
  • Pour obtenir la chaîne de l’index 1 jusqu’à la fin en sautant 1 caractère:
    >>> example[1::2]
    'BDFHJLNP'
    

Partitionner des chaînes

On peut partitionner une chaîne en utilisant une autre chaîne en tant que séparateur:

>>> example = 'ABCDEFGHIJKLMNOP'
>>> example.partition('GHI')    # 'forget' est la chaine de séparation
('ABCDEF', 'GHI', 'JKLMNOP')

Le résultat est un tuple.

On peut effectuer une déconstruction avec le tuple et utiliser '_' (ie. underscore) pour ignorer une valeur, par exemple:

>>> example = 'ABCDEFGHIJKLMNOP'
>>> a, _, b = example.partition('GHI')
>>> print(a)
ABCDEF

>>> print(b)
JKLMNOP

Quelques autres fonctions

Les autres fonctions intéressantes pour chaînes sont:

  • capitalize() pour mettre la 1ère lettre de la chaîne en majuscule, par exemple:
    >>> example = 'hello'
    >>> example.capitalize()
    'Hello'
    
  • replace() pour remplacer une chaîne par une autre, par exemple:
    >>> example = 'hello hello hello'
    >>> example.replace('he', 'a')
    'allo allo allo'
    
  • isalpha() renvoie True si la chaîne contient seulement des caractères alphabétiques, par exemple:
    >>> example = 'hello'
    >>> example.isalpha()
    True
    
  • isdigit() renvoie True si la chaîne contient seulement des caractères numériques, par exemple:
    >>> example = '1234'
    >>> example.isdigit()
    True
    

Bytes

Le type bytes correspond à une suite d’octets. La valeur de cette suite peut être représentée sous la forme d’une chaîne de caractères en UTF-8 par défaut. On peut définir une suite de bytes en préfixant une chaîne de caractères avec b'...':

example = b'AbCdE123456789'

Cette écriture permet de définir une suite de bytes en convertissant chaque caractère encodé en UTF-8.

Il ne s’agit pas d’une chaîne de caractères mais bien d’une suite de bytes:

>>> type(example)
bytes

On peut passer d’une suite de bytes vers une chaîne de caractères et inversement en utilisant les fonctions encode()/decode():

  • encode(): pour passer d’une chaîne de caractères vers une suite de bytes:
    >>> string_object = 'ABCDEF'
    >>> bytes_object = string_object.encode()
    >>> print(bytes_object)
    b'ABCDEF'
    
  • decode(): pour passer d’une suite de bytes vers une chaîne de caractères:
    >>> bytes_object = b'ABCDEF'
    >>> string_object = bytes_objet.decode()
    >>> print(string_object)
    

L’encodage par défaut est UTF-8, on peut donc utiliser des caractères spéciaux:

>>> string_object = 'Caractères spéciaux ©'
>>> bytes_object = string_object.encode()
>>> print(bytes_object)
b'Caract\xc3\xa8res sp\xc3\xa9ciaux \xc2\xa9'

Si on tente d’encoder en ASCII les caractères spéciaux ne pourront pas être encodés:

>>> bytes_object = string_object.encode('ascii')
>>> print(bytes_object)
ERREUR

On peut ajouter des options à la fonction encode() pour gérer les caractères qui ne peuvent pas être encodés:

  • 'backslashreplace': utilise le caractère antislash pour les caractères qui ne peuvent pas être encodé.
  • 'ignore' ignore les caractères ne pouvant pas être encodés.
  • 'namereplace' remplace le caractère ne pouvant pas être encodés avec le nom du caractère.
  • 'strict' correspond à la valeur par défaut, une erreur est levée quand l’encodage n’est pas possible.
  • 'replace' remplace les caractères ne pouvant être encodés avec ?.
  • 'xmlcharrefreplace' remplace les caractères non encodables avec le caractère XML correspond.

Par exemple:

>>> string_object = 'Caractères spéciaux ©'
>>> bytes_object = string_object.encode(encoding='ascii', errors='xmlcharrefreplace')
# ou bytes_object = string_object.encode('ascii', 'xmlcharrefreplace')
>>> print(bytes_object)
b'Caractères spéciaux ©'

Portée des variables

La portée des variables est classique c’est-à-dire:

  • La portée d’une variable est locale au bloc dans lequel elle est déclarée et dans ses sous-blocs éventuels.
  • Les boucles et les clauses conditionnelles sont considérées comme des blocs.
  • Une variable est visible dans un bloc courant et dans les sous-blocs mais pas dans les blocs supérieurs.
  • Il est possible d’accéder à une variable d’un bloc supérieur mais pas aux variables de blocs de même niveau ou de niveau inférieur.
  • Une variable est globale lorsqu’elle est déclarée au niveau d’un script Python.
  • Une variable est locale lorsqu’elle est déclarée au niveau d’une classe ou d’une fonction.

Par exemple:

def define_a():
    a = 5
    print('Local a: %s' % a)

def print_a():
    print(a)
    define_a()

print_a()
ERREUR car a n'est pas déclaré à ce niveau

En revanche:

a = 10
define_a()
print_a()
Local a: 5    # a déclarée dans devine_a() est locale
10            # a déclarée à l'extérieur est globale

global

On peut utiliser ce mot-clé pour indiquer qu’on souhaite manipuler une variable globale, par exemple:

def define_a():
    global a
    a = 5
    print('Local a: %s' % a)

def print_a():
    print(a)
    a  = 10

define_a()
print_a()
Local a: 5    # a est modifiée au niveau global
5

globals() et locals()

globals() et locals() permettent de modifier la valeur de variables. Elles retournent un dictionnaire contenant toutes les variables rangées par nom. On peut directement modifier la valeur en intervenant sur le dictionnaire renvoyé.

Ainsi:

  • globals(): permet d’accéder aux objets globaux du bloc courant.

    Par exemple:

    def define_a():
        globals()['a'] = 5
        print('Local a: %s' % a)
    
    def print_a():
        print(a)
        a  = 10
    
    define_a()
    print_a()
    
    Local a: 5
    5
    
  • locals(): permet d’accéder aux objets locaux du bloc courant.

id()

Permet de renvoyer l’identifiant d’une variable:

id(<variable>)

La copie de valeur se fait par référence:

>>> a = 3
>>> id(a)
10935552

>>> b = a
>>> id(b)
10935552

>>> a = 5
>>> id(a)
10935616  # Nouvelle référence

>>> id(b)
10935552

Les conditions

Les opérateurs de comparaison en Python sont:

  • == pour évaluer l’égalité. Il s’applique aux nombres et aux chaînes de caractères.
  • != pour évaluer une inégalité. Cette opérateur s’applique aussi aux nombres et aux chaînes de caractères.
  • Les comparaisons avec <, <=,> et >=.

    Dans le cas de chaînes de caractères, ces opérateurs peuvent aussi être utilisés toutefois ils effectuent une comparaison des valeurs Unicode des caractères de la chaine en commençant par le premier index jusqu’au dernier. Ainsi:

    • '4' > '31' renvoie True car la valeur Unicode de '4' est supérieure à '3'.
    • '212' < '31' renvoie True.
    • La comparaison de chaînes de caractères peut mener à des erreurs si les évaluations se font avec le mauvais type:
      >>> value1 = '4'
      >>> value2 = '32'
      >>> value1 < value2
      False
      
    • La comparaison entre un nombre et une chaîne peut aussi mener à des erreurs en Python 2 car un nombre est toujours plus petit qu’un chaîne de caractères. En Python 3, une exception est levée.
  • is et is not permettent d’évaluer si 2 objets sont les mêmes ou non. Ainsi si on considère les listes suivantes:
    >>> list1 = [1, 2, 3]
    >>> list2 = [1, 2, 3]
    >>> list1 is list2
    False    # car les objets sont différents
    
    >>> list1 is list1
    True
    

    Mais:

    >>> list1 == list2
    True
    

    Avec les chaînes de caractères:

    >>> str1 = 'content1'
    >>> str2 = 'content1'
    >>> str1 is str2
    True     # car str1 et str2 sont le même objet.
    

    En revanche:

    >>> str3 = 'content'
    >>> str3 += '1'
    >>> str1 is str3
    False
    
Application des opérateurs avec None

Comme indiqué précédemment None est un objet particulier. Il n’est pas l’équivalent de null dans d’autres langages. Ainsi l’application des opérateurs avec None permet d’évaluer si une variable contient None ou si un objet est égal à None:

Si val = None:

>>> val == None
True

>>> val != None
False

>>> None == 0
False      # car on ne peut pas utiliser cette opération pour comparer à 0

>>> None == []
False      # car une liste vide ne correspond pas à None

>>> None == False
False

>>> None > 0
TypeError: '>' not supported between instances of 'NoneType' and 'int'

if…then…else

Le bloc conditionnel if...then...else s’utilise de cette façon:

if <expression à évaluer>:
    <code exécuté si vrai>

Avec else:

if <expression à évaluer>:
    <code exécuté si vrai>
else:
    <code exécuté si faux>

Par exemple:

if number == 5:
    print("number is 5")
else:
    print("number is not 5")

elif

elif permet d’imbriquer plusieurs conditions:

if <expr 1 à évaluer>:
    <code exécuté si expr 1 vrai>
elif <expr 2 à évaluer>:
    <code exécuté si expr 2 vrai>
elif <expr 3 à évaluer>:
    <code exécuté si expr 2 vrai>
...
else:
    <code exécuté si toutes les conditions sont fausses>

Par exemple:

if number > 5:
    print("number is more than 5")
elif number < 5:
    print("number is less than 5")
elif number == 5:
    print("number is 5")
else:
    print("cannot compare to 5")

Version condensée

La version condensée de if...then...else est:

<si vrai> if <condition> else <si faux>

Par exemple:

a = 2
result = ''

if a == 3:
    result = 'OK'
else:
    result = 'KO'

Cette version est équivalente à:

result = 'OK' if a == 3 else 'KO'

bool()

bool() permet de convertir la valeur d’un objet en booléen:

bool(<objet à convertir>).

Suivant la valeur que les objets peuvent prendre, ils peuvent être Falsy ou Truthy.

Opérateurs booléens

Les opérateurs booléens sont: and, or et not pour respectivement le ET logique; OU logique et pour la négation logique.

  • Si on combine plusieurs opérateurs dans une expression à évaluer, on peut utiliser des parenthèses pour se prémunir de la distributivité:
    (<expression 1>) or ((<expressions 2>) and (<expression 3>))
    
  • Lors de l’évaluation d’une expression comme celle-ci:
    <expression 1> and <expressions 2> and <expression 3>
    

    Les expressions sont évaluées successivement dans l’ordre d’apparition. Si une expression est fausse, les évaluations s’arrêtent et les expressions suivantes ne sont pas évaluées. Ainsi, si expression 1 est fausse, alors il n’y aura d’évaluation de l’expression 2 et 3.

  • De même avec une expression du type:
    <expression 1> or <expressions 2> or <expression 3>
    

    Si expression 1 est vraie, les autres expressions ne sont pas évaluées.

    Par exemple:

    >>> 5 > 2 or unknown == 9
    True
    
    >>> (5 < 2) or ((1 < 9) and (9 > 2))
    True
    
    >>> 5 > 2 and 1 < 9 and 9 > 2
    True
    

Ces opérateurs peuvent être utilisés directement avec if...then...else:

number = 3
bool_value = True
if number == 3 and bool_value:
    print("OK")
if number == 17 or not bool_value:
    print("OK")

Les collections

Parmi les collections en Python, on distingue:

  • Les listes: structure ordonnée mutable dont les éléments sont atteignables avec un index. Une liste peut être définie avec [].
  • Les tuples: structure non ordonnée immutable dont les éléments sont atteignables avec leur nom. Un tuple peut être défini avec ().
  • Les dictionnaires: structure non ordonnée mutable dont les éléments sont atteignables avec une clé. Un dictionnaire peut être défini avec {}.
  • Les ensembles (i.e. set): structure non ordonnée mutable dont les éléments ne sont pas directement atteignables. La structure peut être parcourue. Un ensemble peut être défini avec set().

Liste

Une liste peut être initialisée de ces façons:

names = []   # liste vide
names = ['a', 'b', 'c']

On peut atteindre un élément dans la liste en utilisant son index (l’index commence à 0):

>>> print(names[1])
'b'

Une liste est mutable, on peut modifier un élément:

names[0] = 'd'
Les listes ne sont pas typées

On peut ajouter des types différents dans une liste. Les types des éléments ne sont pas obligatoirement les mêmes. Il faut être vigilant sur le type des objets ajoutés à la liste.

>>> elements = [1, '3', 1.5]
>>> type(elements[0])
int

>>> type(elements[1])
str

list()

list() est le constructeur pour créer une nouvelle liste. Pour créer une liste vide, on peut exécuter:

empty_list = list()

Ou plus simplement:

empty_list = []

Si on utilise list() avec une chaîne de caractères, on obtient une liste avec tous les caractères de la chaîne:

>>> caracter_list = list('ABCDEF')
>>> print(caracter_list)
['A', 'B', 'C', 'D', 'E', 'F']

Index

En plus des index normaux, on peut utiliser des index négatifs:
-1 signifie le 1er élément en partant de la fin de la liste:

>>> names = ['a', 'b', 'c', 'd', 'e', 'f']
>>> print(names[-1])
'f'

>>> print(names[-2])
'e'

La syntaxe générale des index est:

[<index de début>:<index de fin exclu>:<pas utilisé>]

Par exemple:

>>> names = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']
>>> names[1:8:2]   # Commence à l'index 1 et s'arrête à l'index 7 (l'index 8 est exclu)
# L'incrément se fait en ajoutant 2 à l'index courant
['b', 'd', 'f', 'h']

Les différents arguments de l’index sont facultatifs:

  • [2:] permet de commencer à l’index 2 (3e élément) jusqu’au dernier.
  • [:3] permet de commencer du début jusqu’à l’index 2 (3e élément). L’index est exclu.
  • [:] désigne tous les éléments de la liste. Cette syntaxe permet d’effectuer une copie de la liste.

On peut utiliser des index négatifs:

>>> names = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']
>>> names[1:-1]  # On commence à l'index 1 et on s'arrête à l'avant dernier élément (le dernier est exclu)
['b', 'c', 'd', 'e', 'f', 'g', 'h', 'i']

Affectation de plusieurs éléments (list slicing)

On peut affecter plusieurs éléments en une seule ligne en utilisant les index.

Par exemple:

>>> names = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']
>>> names[1:3] = ['B', 'C']     #  on affecte directement des éléments aux index 1 et 2 (3 est exclu)
>>> print(names)
 ['a', 'B', 'C', 'd', 'e', 'f', 'g', 'h', 'i', 'j']

Passage par référence

Les listes sont manipulées par référence, par exemple:

>>> names = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']
>>> other_names = names  # passage par références
>>> names[3] = 'NEW'
>>> print(other_names)
['a', 'b', 'c', 'NEW', 'e', 'f', 'g', 'h', 'i', 'j']

Ajouter des éléments

Pour ajouter des éléments à une liste, plusieurs syntaxes sont possibles:

  • Avec append():
    >>> names = ['a', 'b', 'c']
    >>> names.append('d')
    >>> print(names )
    ['a', 'b', 'c', 'd']
    
  • Avec +: ATTENTION cette syntaxe ne modifie pas la liste mais en crée une nouvelle
    >>> names = ['a', 'b', 'c']
    >>> other_names = names + ['d'] # names n'est pas modifiée
    >>> print(other_names)
    ['a', 'b', 'c', 'd']
    
  • Avec insert():
    La syntaxe de insert() est: insert(<index de l'élément à ajouter>, <élément à insérer>), par exemple:

    >>> names = ['a', 'b', 'c']
    >>> names.insert(1, 'NEW')
    >>> print(names)
    ['a', 'NEW', 'b', 'c']
    

Supprimer un élément

Plusieurs possibilités pour supprimer un élément d’une liste:

  • Avec del: il faut disposer de l’index de élément à supprimer
    >>> names = ['a', 'b', 'c']
    >>> del names[1]
    >>> print(names)
    ['a', 'c']
    
  • On peut trouver l’index en utilisant la fonction index():
    >>> names = ['a', 'b', 'c']
    >>> b_index = names.index('b')
    >>> del names[b_index]
    

    Plus directement:

    >>> del names[names.index('b')]
    >>> print(names)
    ['a', 'c']
    
  • Avec remove():
    >>> names = ['a', 'b', 'c']
    >>> names.remove('b')
    >>> print(names)
    ['a', 'c']
    

Si on essaie de supprimer un élément qui n’existe pas, une erreur ValueError est générée.

Effectuer une copie d’une liste

Plusieurs syntaxes sont possibles pour effectuer une copie:

  • Avec copy():
    >>> names = ['a', 'b', 'c']
    >>> names_copy = names.copy()
    >>> print(names_copy)
    ['a', 'b', 'c']
    
  • En construisant une nouvelle liste avec list():
    >>> names = ['a', 'b', 'c']
    >>> names_copy = list(names)
    >>> print(names_copy)
    ['a', 'b', 'c']
    
  • Avec l’index [:]:
    >>> names = ['a', 'b', 'c']
    >>> names_copy = names[:]
    ['a', 'b', 'c']
    

Des copies des listes sont effectuées toutefois les éléments de la liste ne sont pas dupliqués. Les éléments de la liste étant stockés par référence, la copie de la liste duplique les références mais pas les éléments vers lesquels pointent les références.

len()

La fonction len() permet de renvoyer la taille de la liste:

>>> names = ['a', 'b', 'c']
>>> print(len(names))
3

count()

count() permet de compter le nombre d’occurrences d’un élément dans la liste. Il ne faut pas confondre count() et len(), count() ne permet pas de retourner le nombre d’éléments de la liste:

>>> names = ['a', 'b', 'c', 'b', 'd', 'b']
>>> print(names.count('b'))
3

>>> print(names.count())
ERREUR

Concaténer des listes

Plusieurs syntaxes sont possibles pour effectuer une concaténation de listes:

  • Avec l’opérateur +: cet opérateur crée une nouvelle liste et ne modifie pas une liste existante:
    >>> first = [1, 2, 3, 4]
    >>> second = [5, 6, 7, 8]
    >>> print(first + second)
    [1, 2, 3, 4, 5, 6, 7, 8]
    
  • Avec extend():

    extend() modifie la liste dans laquelle elle est exécutée:

    >>> names = ['a', 'b', 'c']
    >>> names.extend(['d', 'e', 'f'])
    >>> print(names)
    ['a', 'b', 'c', 'd', 'e', 'f']
    

Répéter le contenu d’une liste (avec *)

L’opérateur * permet de répéter le contenu d’une liste en générant une nouvelle liste:

>>> names = ['a', 'b', 'c']
>>> print(names * 3)
['a', 'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c']

in

L’opérateur in permet de vérifier si un élément est dans une liste et renvoie True si c’est le cas:

>>> names = ['a', 'b', 'c']
>>> print('c' in names)
True

Liste de listes

On peut imbriquer des listes les unes dans les autres, par exemple:

>>> nested_list = [['1', '2', '3', '4'], ['a', 'b', 'c', 'd'], ['α', 'β', 'γ', 'δ']]
>>> print(nested_list[1])
>>> print(nested_list)
['a', 'b', 'c', 'd']

On peut accéder aux éléments en utilisant 2 index:

>>> print(nested_list[1][0])
'a'

Inverser l’ordre des éléments

2 syntaxes permettent d’inverser l’ordre des éléments directement:

  • Avec reverse(): cette fonction modifie la liste dans laquelle elle est exécutée:
    >>> names = ['a', 'b', 'c']
    >>> names.reverse()
    >>> print(names)
    ['c', 'b', 'a']
    
  • Avec reversed(): cette fonction permet de créer un itérateur permettant de parcourir la liste dans un ordre inversé:
    >>> names = ['a', 'b', 'c']
    >>> names_reversed_it = reversed(names)
    # names_reversed_it est un itérateur
    
    >>> print(type(names_reversed_it))
    <class 'list_reverseiterator'>
    

    On peut parcourir avec l’itérateur:

    for name in names_reversed_it:
        print(name)
    
    'c'
    'd'
    'e'
    

    On peut créer une nouvelle liste avec list():

    >>> names = ['a', 'b', 'c']
    >>> names_reversed_it = reversed(names)
    >>> names_reversed = list(names_reversed_it)
    >>> print(names_reversed)
    ['c', 'b', 'a']
    

Ordonner les éléments de la liste

Plusieurs syntaxes sont possibles pour ordonner les éléments de la liste:

  • Avec sort(): cette fonction modifie la liste dans laquelle elle est exécutée, par défaut sort() ordonne par ordre alphabétique croissant:
    >>> names = ['d', 'j', 'h', 'c', 'g', 'b', 'a', 'f', 'i', 'e']
    >>> names.sort()
    >>> print(names)
    ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']
    
  • Avec sorted(): cette fonction permet de créer une autre liste qui sera ordonnée, par défaut sorted() ordonne par ordre alphabétique croissant:
    >>> names = ['d', 'j', 'h', 'c', 'g', 'b', 'a', 'f', 'i', 'e']
    >>> names_sorted = names.sorted()
    >>> print(names)
    >>> print(names_sorted)
    ['d', 'j', 'h', 'c', 'g', 'b', 'a', 'f', 'i', 'e']
    ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j']
    

    Il est possible d’utiliser cette syntaxe:

    >>> names_sorted = sorted(names)
    

sort() et sorted() autorisent des options:

  • reverse=True pour ordonner par ordre alphétique décroissant:
    >>> names.sort(reverse=True)
    >>> print(names)
    ['j', 'i', 'h', 'g', 'f', 'e', 'd', 'c', 'b', 'a']
    
  • key: permet d’effectuer l’ordonnancement suivant l’exécution d’une fonction particulière sur chaque élément de la liste.
    • Par exemple si on considère une liste de chaînes de caractères et si on applique la fonction len() sur les éléments de la liste:
      >>> words = [ 'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta']
      >>> words.sort(key=len)
      >>> print(words)
      ['Beta', 'Zeta', 'Alpha', 'Gamma', 'Delta', 'Epsilon']
      
    • Avec une lambda: dans cet exemple, on ordonne suivant la 2e lettre de chaque chaîne:
      >>> words = [ 'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta']
      >>> words.sort(key=lambda str: str[1])
      >>> print(words)
      ['Gamma', 'Beta', 'Delta', 'Zeta', 'Alpha', 'Epsilon']
      
    • Avec itemgetter(): cette fonction prend en paramètre un index et renvoie l’élément correspondant à l’index:
      >>> from operator import itemgetter
      >>> f = itemgetter(2)  # Renvoie la 3e lettre
      >>> str = 'example'
      >>> f(str)
      'a'
      

      Si on applique avec key: dans cet exemple le tri se fait suivant la 3e lettre de chaque chaîne:

      >>> words = [ 'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta']
      >>> words.sort(key=itemgetter(2))
      >>> print(words)
      ['Delta', 'Gamma', 'Alpha', 'Epsilon', 'Beta', 'Zeta']
      
    • Avec attrgetter(): cette fonction prend en paramètre une liste de noms d’attributs et renvoie un tuple avec les valeurs correspondantes.
    • Avec methodcaller(): cette fonction prend en paramètre le nom d’une fonction et renvoie l’exécution de cette fonction sur un objet particulier:
      >>> from operator import methodcaller
      >>> f = methodcaller('index', 'a')  # Renvoie index('a')
      >>> str = 'example'
      >>> f(str)
      2   # Index 2 car 'a' est la 3e lettre de 'example'
      

      Si on applique avec key: dans cet exemple le tri se fait suivant la position de la lettre 'a' dans les chaînes:

      >>> words = [ 'Alpha', 'Beta', 'Gamma', 'Delta', 'Zeta']
      >>> words.sort(key=methodcaller('index', 'a'))
      >>> print(words)
      ['Gamma', 'Beta', 'Zeta', 'Alpha', 'Delta']
      

Déconstruction

L’opération de déconstruction est possible avec une liste.

Par exemple si on considère la liste suivante:

words = [ 'Alpha', 'Beta', 'Gamma']

On peut effectuer une déconstruction dans les objets en exécutant:

>>> word1, word2, word3 = words
>>> print(word1)
'Alpha'

>>> print(word2)
'Beta'

>>> print(word3)
'Gamma'

Tuples

Un tuple est une collection immutable. Comme pour les listes, les éléments sont accessibles en utilisant un index et un tuple peut contenir des objets de type différent.

Par exemple:

>>> tuple_example = ('A', 1, 1.0)   # Ce tuple contient 3 éléments
>>> print(tuple_example[1])
'A'

>>> print(tuple_example[2])
1

>>> print(tuple_example[3])
1.0

Les parenthèses sont facultatives:

>>> tuple_example = 'A', 1, 1.0
>>> type(tuple_example)
tuple

Un tuple vide se définit de cette façon:

empty_tuple = ()

Le tuple étant immutable, il n’est pas possible d’y ajouter ou de supprimer des éléments.

tuple()

Ce constructeur permet de créer des tuples:

  • Un tuple vide:
    empty_tuple = tuple()
    
  • Un tuple à partir d’une liste:
    >>> words = [ 'Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta']
    >>> word_tuple = tuple(words)
    >>> print(word_tuple)
    ('Alpha', 'Beta', 'Gamma', 'Delta', 'Epsilon', 'Zeta')
    
  • Un tuple à partir d’une chaîne de caractères:
    >>> word = 'example'
    >>> word_tuple = tuple(word)
    >>> print(word_tuple)
    ('e', 'x', 'a', 'm', 'p', 'l', 'e')
    

len()

Cette fonction renvoie la taille d’un tuple:

>>> tuple_example = ('A', 1, 1.0)
>>> print(len(tuple_example))
3

count()

Permet de compter le nombre d’occurences d’un élément dans un tuple:

>>> letters = ('a', 'b', 'c', 'b', 'd', 'b')
>>> print(letters.count('b'))
3

Concaténation d’un tuple (avec +)

On peut utiliser + pour concatener un tuple avec un tuple. Le résultat fournit un 3e tuple:

>>> tuple1 = ('Alpha', 'Beta', 'Gamma')
>>> tuple2 = ('a', 'b', 'c')
>>> result_tuple = tuple1 + tuple2
>>> print(result_tuple)
('Alpha', 'Beta', 'Gamma', 'a', 'b', 'c')

Répéter le contenu d’un tuple (avec *)

L’opérateur * permet de répéter le contenu d’un tuple. Un nouveau tuple est généré:

>>> letters = ('a', 'b', 'c')
>>> print(letters * 3)
('a', 'b', 'c', 'a', 'b', 'c', 'a', 'b', 'c')

in/not in

L’opérateur in permet de vérifier si un élément est dans un tuple. Il renvoie True si c’est le cas:

>>> letters = ('a', 'b', 'c')
>>> print('c' in letters)
True

A l’opposé, not in renvoie True si un élément n’est pas dans un tuple:

>>> letters = ('a', 'b', 'c')
>>> print('d' not in letters)
True

Tuple de tuples

On peut imbriquer des tuples les uns dans les autres, par exemple:

>>> nested_tuple = (('1', '2', '3', '4'), ('a', 'b', 'c', 'd'), ('α', 'β', 'γ', 'δ'))
>>> print(nested_tuple[1])
('a', 'b', 'c', 'd')

Pour accéder aux éléments, il faut utiliser 2 index:

>>> print(nested_tuple[1][2])
c

zip()

Cette fonction permet de créer des tuples à partir des éléments de listes.

Par exemple:

index_list = [1,2,3,4]
element_list = ['a','b','c','d']
zip_object = zip(index_list, element_list)

L’objet zip_object est de type zip. On peut créer une liste à partir de cet objet pour obtenir une liste de tuples:

>>> items = list(zip_object)
>>> print(items)
[(1, 'a'), (2, 'b'), (3, 'c'), (4, 'd')]

Déconstruction

La déconstruction d’un tuple permet d’effectuer en une ligne des affectations des éléments d’un tuple dans des objets séparés.

Par exemple:

words = ( 'Alpha', 'Beta', 'Gamma')

On peut effectuer une déconstruction dans les objets en exécutant:

>>> word1, word2, word3 = words
>>> print(word1)
'Alpha'

>>> print(word2)
'Beta'

>>> print(word3)
'Gamma'

Dictionnaire

Un dictionnaire est une structure dont les éléments sont sockés sous forme de clé/valeur. Les valeurs d’un dictionnaire peuvent être atteintes en utilisant les clés correspondantes. La clé doit être unique pour chaque éléments et doit être immutable.

Un dictionnaire est un objet mutable.

A partir de Python 3.7, l’ordre de parcours des éléments d’un dictionnaire est garanti.

Pour initialiser un dictionnaire, il faut utiliser les caractères {}:

persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }

Les clés sont '1', '2', '3' et '4', les valeurs sont 'Mark', 'Elon', 'Jeff' et 'Bill'.

Pour obtenir une valeur à partir de sa clé:

>>> print(persons['2'])
'Elon'

Un dictionnaire peut être initialisé de cette façon:

empty_dictionary = {}

Comme pour les listes, on peut stocker des objets de type différent dans un dictionnaire aussi bien pour les clés que pour les valeurs:

persons = { '1': 'Mark', 2: 'Elon', 3.0: 'Jeff', '4': 4  }

Si on utilise un clé qui n’existe pas, une erreur KeyError est levée:

>>> persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
>>> persons['5']
KeyError: '5'

Modifier une valeur

Comme le dictionnaire est mutable, on peut en modifier une valeur en utilisant l’index avec une clé.

Par exemple:

>>> persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
>>> persons['1'] = 'Guido'
>>> print(persons)
{'1': 'Guido', '2': 'Elon', '3': 'Jeff', '4': 'Bill'}

Si la clé n’existe pas dans le dictionnaire, une nouvelle valeur sera rajoutée:

>>> persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
>>> persons['5'] = 'Guido'
>>> print(persons)
{'1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill', '5': 'Guido'}

update()

La fonction update() peut être utilisée pour rajouter ou modifier les valeurs dans un dictionnaire:

  • Si la clé existe alors la valeur est remplacée
  • Si la clé n’existe pas, le couple clé/valeur est rajouté.

Par exemple:

>>> persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
>>> new_persons = [('1', 'Guido'), ('5', 'Grace')]
>>> persons.update(new_persons)
>>> print(persons)
{'1': 'Guido', '2': 'Elon', '3': 'Jeff', '4': 'Bill', '5': 'Grace'}
# La clé '1' existait déjà et a été modifié. La clé '5' n'existait pas.

Supprimer une clé/valeur

Pour supprimer une clé et la valeur correspondante dans un dictionnaire, il faut utiliser l’opérateur del. Le couple clé/valeur est directement supprimé dans le dictionnaire, par exemple:

>>> persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
>>> del persons['1']
>>> print(persons)
{'2': 'Elon', '3': 'Jeff', '4': 'Bill'}

get()

Cette fonction permet de récupérer une valeur dans un dictionnaire sans qu’une erreur ne soit levée si la clé n’existe pas. La syntaxe de get() est:

<valeur ou valeur de retour> = <dictionnaire>.get(<clé>, <valeur de retour si la clé n'existe pas>)

Le paramètre <valeur de retour si la clé n'existe pas> est facultatif. S’il n’est pas présent, la valeur retournée est None si la clé n’existe pas dans le dictionnaire.

Par exemple:

>>> persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
>>> print(persons.get('3'))
'Jeff'   # La clé existe alors la valeur correspondante est retournée

>>> print(persons.get('5'))
None      # La clé '5' n'existe pas donc None est retournée

>>> print(persons.get('5', 'Unknown'))
'Unknown'    #  La clé '5' n'existe pas donc la valeur par défaut est retournée

dict()

dict() est un constructeur permettant de créer un dictionnaire:

  • Un dictionnaire vide:
    empty_dict = dict()
    
  • Créer un nouveau dictionnaire à partir d’un dictionnaire existant:
    >>> persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
    >>> persons_copy = dict(persons)
    >>> print(id(persons))
    281472705125760
    
    >>> print(id(persons_copy))
    281472704300608
    
  • A partir d’une liste de tuple:
    >>> tuple_list = [( '1', 'Mark'), ('2', 'Elon'), ('3', 'Jeff'), ('4', 'Bill')]
    >>> persons = dict(tuple_list)
    >>> print(persons)
    {'1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'}
    
  • Le constructeur peut être utilisé en indiquant les clés/valeurs avec la syntaxe <clé> = <valeur>:
    >>> persons = dict(key1 = 'Mark', key2= 'Elon', key3= 'Jeff', key4= 'Bill')
    >>> print(persons)
    {'key1': 'Mark', 'key2': 'Elon', 'key3': 'Jeff', 'key4': 'Bill'}
    

keys()

keys() est une fonction du dictionaire permettant de retourner un objet itérable contenant toutes les clés.

Par exemple:

>>> persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
>>> persons_keys = persons.keys()
>>> type(persons_keys)
dict_keys   # Le type retourné est dict_keys.

Pour obtenir une liste à partir de cet objet, on peut utiliser le constructeur list():

>>> key_list = list(persons_keys)
>>> print(key_list)
['1', '2', '3', '4']

values()

values() est une fonction du dictionnaire renvoyant un objet itérable contenant toutes les valeurs du dictionnaire.

Par exemple:

>>> persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
>>> persons_values = persons.values()
>>> type(persons_values)
dict_values

Pour obtenir une liste à partir de cet objet, on peut utiliser le constructeur list():

>>> value_list = list(persons_values)
>>> print(value_list)
['Mark', 'Elon', 'Jeff', 'Bill']

Parcourir les valeurs d’un dictionnaire

L’objet dictionnaire est itérable. A partir de Python 3.7, l’ordre de parcours d’un dictionnaire est garanti. Si on itère directement sur un dictionnaire, on itére sur les clés:

persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
for person_key in persons:
    print(person_key)
1
2
3
4

On peut itérer directement sur les clés et les valeurs en utilisant items(). items() permet de créer un itérable de tuples sur les objets du dictionnaire:

for person_key, person_value in persons.items():
    print('Key: %s/Value: %s' % (person_key, person_value))
Key: 1/Value: Mark
Key: 2/Value: Elon
Key: 3/Value: Jeff
Key: 4/Value: Bill

Pour avoir la liste de tuples, on peut exécuter:

>>> values_list = list(persons.items())
>>> print(values_list)
[('1', 'Mark'), ('2', 'Elon'), ('3', 'Jeff'), ('4', 'Bill')]

copy()

copy() permet d’effectuer une copie d’un dictionnaire. Les références sont copiées mais les éléments ne sont pas dupliqués.

Par exemple:

>>> persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }
>>> persons_copy = persons.copy()
>>> print(persons_copy)
{'1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'}

Dictionnaires imbriqués

Il est possible de construire des dictionnaires imbriqués. L’accès aux valeurs se fait en utilisant plusieurs index, par exemple:

pilots = { 'first': {'name': 'Armstrong', 'firstname': 'Neil'},
'second': {'name': 'Aldrin', 'firstname': 'Buzz'},
'third': {'name': 'Collins', 'firstname': 'Michael'}}
print(pilots)
{'first': {'name': 'Armstrong', 'firstname': 'Neil'}, 'second': {'name': 'Aldrin', 'firstname': 'Buzz'}, 'third': {'name': 'Collins', 'firstname': 'Michael'}}

Pour accéder à un élément:

>>> print(pilots['second']['firstname'])
Buzz

in/not in

L’opérateur in permet de vérifier si un élément est parmi les clés d’un dictionnaire. Il renvoie True si c’est le cas:

>>> persons = {'1': 'Guido', '2': 'Elon', '3': 'Jeff', '4': 'Bill', '5': 'Grace'}
>>> print('2' in persons)
True

>>> print('2' not in persons)
False

set

Un set (i.e. ensemble) est une structure non ordonnée d’objets uniques. Un set est un objet mutable. Les objets ajoutés dans un set doivent être uniques et immutables.

Pour initialiser un set, il faut utiliser les caractères {}:

persons = { 'Guido', 'Ada', 'Alan', 'Bjarne', 'Grace' }

on ne peut pas instancier un set vide de cette façon:

empty_set = {}     # dictionnaire

empty_set est, dans ce cas, un dictionnaire vide. Pour instancier un set vide, il faut utiliser le constructeur:

empty_set = set()

Un dictionnaire peut être initialisé de cette façon:

empty_dictionary = {}

Les objets dans un set peuvent être de type différent toutefois ils doivent être uniques. Ainsi:

>>> object_set = { '1', '2', 1, False, 2.0 }
>>> print(object_set)
{False, 1, 2.0, '2', '1'}

L’ordre des objets n’est pas le même.

Si on tente d’ajouter True, l’objet ne sera pas ajouté:

>>> object_set.add(True)
>>> object_set.add(1.0)
>>> print(object_set)
{False, 1, 2.0, '2', '1'}

Car 1 == True et 1 == 1.0

Les objets ajoutés doivent être immutables. Par exemple, si on tente d’ajouter une liste (qui est un objet mutable):

>>> object_set.add([0, 1])
TypeError: unhashable type: 'list'

set()

Le constructeur permet de créer un nouveau set:

empty_set = set()

On peut créer un set à partir d’un autre set, d’une liste, d’un tuple, d’un dictionnaire ou d’une chaîne de caractères:

>>> list_with_duplicates = [1, 2, 3, 2, 1, 4, 1, 3, 2]    # Liste
>>> set_without_duplicates = set(list_with_duplicates)
>>> print(set_without_duplicates)
{1, 2, 3, 4}

Les duplicats n’ont pas été ajoutés.

>>> tuple_example = (1, 2, 3, 2, 1, 4, 1, 3, 2)     # Tuple
>>> set_without_duplicates = set(tuple_example)
>>> print(set_without_duplicates)
{1, 2, 3, 4}

>>> dictionary_example = {1: 'One', 2: 'Two', 3: 'Three'}
>>> set_from_dictionary = set(dictionary_example)
>>> print(set_from_dictionary)
{1, 2, 3}

Seulement les clés sont rajoutées au set:

>>> string_example = 'Example of string'
>>> set_from_string = set(string_example)
>>> print(set_from_string)
{'a', 'e', 'n', 'i', 'f', 's', 'm', 'x', 'p', 'E', 'l', ' ', 'r', 'g', 'o', 't'}

>>> set1 = {1, 2, 3, 4}
>>> set2 = set(set1)
>>> print(set2)
{1, 2, 3, 4}

add()

Permet de rajouter un élément à un set. Si l’élément est déjà présent, il ne sera pas rajouté:

>>> set_example = { 1, 2, 3}
>>> set_example.add(1)
>>> print(set_example)
{1, 2, 3}

>>> set_example.add(True)
>>> print(set_example)
{1, 2, 3}

True n’est pas rajouté car 1 == True.

update()

update() permet de rajouter plusieurs éléments dans un set à partir d’un itérable (liste, tuple, dictionnaire etc…):

>>>set_example = { 1, 2, 3}
>>> set_example.update([ 2, 3, 4, 5])
>>> print(set_example)
{1, 2, 3, 4, 5}

Les duplicats ne sont pas rajoutés.

A partir d’un dictionnaire, seulement les clés sont itérées:

>>> set_example = { 1, 2, 3}
>>> set_example.update({2: 'Two', 3: 'Three', 4: 'Four'})
>>> print(set_example)
{1, 2, 3, 4}

Supprimer un élément d’un set

On peut utiliser plusieurs méthodes pour supprimer un élément d’un set:

  • remove(<élément à supprimer>):

    Par exemple:

    >>> set_example = { 1, 2, 3}
    >>> set_example.remove(2)
    >>> print(set_example)
    {1, 3}
    

    Si l’élément n’existe pas, une erreur est générée:

    >>> set_example.remove(4)
    ERREUR: KeyError: 4
    
  • discard(<élément à supprimer>):
    discard() permet de supprimer un élément sans générer d’erreur si l’élément n’existe pas dans le set.

    Par exemple:

    >>> set_example = { 1, 2, 3}
    >>> set_example.discard(4)
    >>> print(set_example)
    {1, 2, 3}
    

Effectuer une copie d’un set

On peut utiliser 2 syntaxes:

  • copy(): par exemple:
    >>> set1 = { 1, 2, 3, 4}
    >>> set2 = set1.copy()
    >>> print(set2)
    { 1, 2, 3, 4}
    
    >>> set1.remove(2)
    >>> print(set1)
    >>> print(set2)
    { 1, 3, 4}
    { 1, 2, 3, 4}   # set2 n'est pas modifié
    
  • set(): utiliser le constructeur permet de copier un set:
    >>> set1 = { 1, 2, 3, 4}
    >>> set2 = set(set1)
    >>> print(set2)
    { 1, 2, 3, 4}
    

in/not in

L’opérateur in permet de vérifier si un élément se trouve dans un set. Il renvoie True si c’est le cas:

>>> set_example = { 1, 2, 3, 4, 5 }
>>> print(2 in set_example)
True

>>> print(2 not in set_example)
False

Opérations applicables sur les sets

On peut appliquer des opérations ensemblistes sur les sets, par exemple si on considère les sets:

set1 = {1, 2, 3}
set2 = {2, 3, 4}

Chaque opération génère un set et ne modifie pas le set à partir duquel la fonction est exécutée:

  • union(): retourne un set comportant les éléments uniques de set1 et set2:
    >>> print(set1.union(set2))
    {1, 2, 3, 4}
    
  • intersection(): retourne un set comportent les éléments communs entre set1 et set2:
    >>> print(set1.intersection(set2))
    {2, 3}
    
  • intersection_update(): même fonction que intersection() mais set1 est modifié:
    >>> set1.intersection_update(set2)
    >>> print(set1)
    {2, 3}
    
  • difference(): retourne un set avec les éléments de set1 après avoir supprimé les éléments se trouvant dans set2:
    >>> print(set1.difference(set2))
    {1}
    
  • difference_update(): même fonction que difference() mais set1 est modifié:
    >>> set1.difference_update(set2)
    >>> print(set1)
    {1}
    
  • symmetric_difference(): retourne un set avec les éléments de set1 après avoir supprimé les éléments se trouvant dans set2. Les éléments de set2 ne se trouvant pas dans le set1 sont ajoutés.
    >>> print(set1.symmetric_difference(set2))
    {1, 4}
    
  • symmetric_difference_update(): même fonction symmetric_difference() mais set1 est modifié:
    >>> set1.symmetric_difference_update(set2)
    >>> print(set1)
    {1, 4}
    
  • issubset(): retourne True si set1 est un sous-ensemble de set2:
    >>> print(set1.issubset(set2))
    False
    
    >>> set2 = {2, 3, 4}
    >>> set3 = {2, 3}
    >>> print(set3.issubset(set2))
    True
    
  • isdisjoint(): retourne True si aucun élément n’est commun entre set1 et set2:
    >>> print(set1.isdisjoint(set2))
    False
    
    >>> set2 = {2, 3, 4}
    >>> set4 = {1, 5}
    >>> set2.isdisjoint(set4)
    True
    

Itérable

Un itérable est un objet dont on peut parcourir les objets avec une boucle for. Les structures comme les listes, les sets, les dictionnaires ou les tuples sont des itérables. Ainsi:

  • Un itérable: on peut obtenir un iterator à partir d’un itérable en utilisant la fonction iter():
    iterator = iter(iterable)
    
  • Un iterator: objet implémentant le design pattern iterator. Appliqué sur un itérable, un iterator permet d’obtenir l’élément suivant en utilisant la fonction next():
    item = next(iterator)
    

Par exemple, si on considère la liste suivante:

>>> iterable = ['Spring', 'Summer', 'Autumn', 'Winter']
>>> iterator = iter(iterable)
>>> next(iterator)

On obtient:

  • 1ère exécution: 'Spring',
  • 2e exécution: 'Summer',
  • 3e exécution: 'Autumn',
  • 4e exécution: 'Winter'
  • 5e exécution: à la fin, si on exécute l’itérateur pour avoir l’élément suivant, on obtient une exception StopIteration. Cette erreur survient si l’iterable est vide.

Les fonctions suivantes s’appliquent sur des itérables:

  • all(): renvoie True si tous les éléments de l’itérable sont considérés comme vrai (au sens Truthy/Falsy).
  • any(): renvoie True si au moins un élément de l’iterable est considéré comme vrai (au sens Truthy/Falsy).

Fonctions

Pour définir une fonction, on utilise le mot-clé def:

def <nom fonction>(<arguments>):
    <corps de la fonction>

Par exemple:

def get_title_case(input):
    return input.title()
>>> print(get_title_case('example'))
Example

Dans le cas d’une méthode (qui ne renvoie rien), le retour est None:

def print_with_title_case(input):
    print(input.title())
>>> result = print_with_title_case('example')
>>> print(result)
None

Arguments

Les arguments sont passés par référence.

Par exemple si on considère la fonction suivante:

def remove_first_item(items):
    del items[0]

Si on effectue l’exécution suivante:

items = [1, 2, 3, 4]
print(items)
remove_first_item(items)
print(items)

Comme l’objet items est passé en paramètre de la fonction par référence, la modification à l’intérieur de méthode modifie directement la liste.

Paramètre par défaut

On peut indiquer la valeur par défaut de paramètres. Si l’argument n’est pas précisé lors de l’appel de la fonction c’est la valeur par défaut qui sera utilisée.

Par exemple, si on considère la méthode:

def remove_item(items, index = 0):
    del items[index]

On peut préciser une valeur pour l’argument index ou l’omettre:

>>> items = [1, 2, 3, 4]
>>> remove_item(items)
>>> print(items)
[2, 3, 4]

L’élément à l’index 0 a été supprimé

>>> items = [1, 2, 3, 4]
>>> remove_item(items, 2)
>>> print(items)
[1, 2, 4]

L’élément à l’index 2 a été supprimé

L’argument par défaut est évalué quand la fonction est lue à l’exécution par le runtime

Par exemple si on considère cette méthode:

import time
def print_current_time(arg=time.ctime()):
    print(arg)

Si on exécute cette méthode sans préciser de paramêtres:

print_current_time()

La valeur affichée sera toujours la même car l’argument est évalué une seule fois au moment où la déclaration de la méthode est lue. Pour éviter ces problèmes, il faut privilégier des objets immutables pour les arguments par défaut.

Préciser le nom des arguments

Il est possible d’indiquer le nom des arguments lors d’un appel.

Par exemple, si on considère la méthode:

def print_strings(string1, string2, string3):
    print('string1: {0}'.format(string1))
    print('string2: {0}'.format(string2))
    print('string3: {0}'.format(string3))

On peut effectuer les appels suivants:

>>> print_strings('1', '2', '3')
>>> print_strings(string1='1', string2='2', string3='3')     # En nommant les arguments
>>> print_strings(string3='3', string2='2', string1='1')     # En changeant l'ordre des arguments
>>> print_strings('1', string2='2', string3='3')      # Il n'est pas nécessaire de nommer tous les arguments
>>> print_strings('1', '2', string3='3')
>>> print_strings('3', '2', string1='1')       # ERREUR: string1 possède plusieurs valeurs
>>> print_strings('1', string2='2', '3')       # ERREUR: si on nomme l'argument string2, il faut nommer aussi string3

Nombre variable d’arguments

On peut définir une méthode avec un nombre variable d’arguments en nommant la variable *<nom variable>, par exemple:

def var_args(name, *args):
    print(type(args))
    print(args) # args est un tuple

On peut appeler la méthode de ces façons:

  • var_args('misc', 2, 3, 4)
    <class 'tuple'>
    (2, 3, 4)
    
  • var_args('misc', *[2, 3, 4])
    <class 'tuple'>
    (2, 3, 4)
    

    Il faut faire attention à ne pas oublier * avec la liste sinon c’est comme s’il n’y avait qu’un seul argument.

    Si on omet * devant la liste: var_args('misc', [2, 3, 4])

    <class 'tuple'>
    ([2, 3, 4],)
    

    Il s’agit d’un tuple contenant un seul élément de type liste.

  • list_args = [2, 3, 4]
    var_args('misc', *list_args)

    <class 'tuple'>
    (2, 3, 4)
    

Arguments variables indiqués sous forme d’un dictionnaire

Les arguments peuvent être indiqués sous la forme d’un dictionnaire en nommant la variable **<nom variable>, par exemple:

def var_args(name, **args):
    print(type(args))
    print(args) # args est un dictionnaire

On peut appeler la méthode de ces façons:

  • var_args('misc', arg1=4, arg2=3, arg3=2)
    <class 'dict'>
    {'arg1': 4, 'arg2': 3, 'arg3': 2}
    

    Les clés sont indiquées sous forme de chaîne de caractères.

  • var_args('misc', **{'arg1': 4, 'arg2': 3, 'arg3': 2})
    <class 'dict'>
    {'arg1': 4, 'arg2': 3, 'arg3': 2}
    
  • dict_args = {'arg1': 4, 'arg2': 3, 'arg3': 2}
    var_args('john', **dict_args)

    <class 'dict'>
    {'arg1': 4, 'arg2': 3, 'arg3': 2}
    

Fonctions imbriquées

On peut définir des fonctions dans d’autres fonctions (i.e. nested function).

Par exemple:

def get_items_with_title_case():
    items = ['one', 'two', 'three']

    def get_title_case():
        items_titlecase = []

        for item in items:
            items_titlecase.append(item.title())

        return items_titlecase

    titlecases = get_title_case()
    print(titlecases)

Si on appelle la méthode:

>>> get_items_with_title_case()
['One', 'Two', 'Three']

La fonction imbriquée a accès aux variables de la fonction parente.

Fonctions de premier ordre

On peut transmettre des fonctions en paramètre d’autres fonctions, par exemple si on considère les 2 fonctions suivantes:

def print_fctn_result(n, fctn_to_execute):
    print(type(fctn_to_execute))
    for i in range(n):
        print(fctn_to_execute(i))

def power_of_2(x):
    return x ** 2

On peut effectuer l’appel en fournissant la méthode power_of_2() en tant qu’argument:

>>> print_fctn_result(10, power_of_2)
<class 'function'>
0
1
4
9
16
25
36
49
64
81

Quelques fonctions particulières

map()

map est un objet qui prend des arguments et les passent dans un autre objet, par exemple:

map_example = map(<fonction>, <arguments>)   # la liste des arguments est passée à la fonction

Par exemple si on déclare la fonction:

def addition(n):
    return n + n
>>> numbers = (1, 2, 3, 4)
>>> result = map(addition, numbers)   # result est un objet map
>>> print(list(result))               # Pour créer une liste il faut exécuter list()
[2, 4, 6, 8]

Avec une lambda:

>>> map_example = map(lambda x:x, [1, 2, 3, 4])
>>> print(map_example)
<map object at 0xffff78f2b310>

>>> print(list(map_example))
[1, 2, 3, 4]

filter()

filter() utilise une lambda renvoyant un booléen pour filtrer une liste:

Par exemple:

>>> filter_example = filter(lambda x:x<3, [1, 2, 3, 4])
>>> print(filter_example)          # filter_example est un objet de type filter
<filter object at 0xffff78f2b190>

>>> print(list(filter_example))    # il faut utiliser list() pour en créer une liste
[1, 2]

reduce()

reduce() permet d’effectuer un traitement sur tous les éléments d’un itérable et de renvoyer un seul objet à la suite de ce traitement.

En entrée, la fonction prend comme argument:

  • une fonction: cette fonction correspond au traitement qui sera appliqué à tous les éléments de l’itérable. La signature de cette fonction doit être:
    result_value = process(value, element)
    

    avec:

    • value: la valeur à retourner par la fonction reduce();
    • element: l’élément courant de l’itérable
    • result_value: le résultat du traitement de la fonction à l’élément courant de l’itérable. Pour chaque élément de l’itérable, result_value devient l’argument value de l’élément suivant.
  • un itérable: c’est la collection d’objets qui sera parcourue.

Par exemple si on considère la fonction:

def add_values(a, b):
    return a + b

Alors on peut appliquer reduce():

from functools import reduce

result = reduce(add_values, [1, 2, 3, 4])
print(result)
10

Fonction lambda

Une fonction lambda est une fonction anonyme c’est-à-dire qu’elle n’a pas de nom.

Pour définir une fonction lambda, on utilise le mot-clé lambda:

multiply_by_2 = lambda x:x*2

multiply_by_2 est le nom de la lambda; x est le seul argument de cette fonction.

Cette fonction peut être appelée comme une fonction normale:

>>> result = multiply_by_2(5)
>>> print(result)
25

Avec plusieurs arguments:

multiply_values = lambda x,y: x * y

Pour effectuer l’appel:

multiply_values(2,5)

Quelques caractéristiques des fonctions lambda en Python:

  • Elles ne peuvent contenir qu’une expression, elles ne peuvent pas contenir des déclarations.
  • Elles ne peuvent comporter qu’une seule ligne.
  • Comme pour les fonctions normales, ce sont des objets de premier ordre. Elles peuvent être transmises en argument.

Par exemple, si on considère la fonction suivante:

def handle_price_from_range(price, operation):
    if price > 1000:
        return operation(price)
    elif price > 500:
        return operation(price/2)
    elif price > 0:
        return operation(price/4)
    else:
        return price

On peut effectuer un appel:

handle_price_from_range(3000, lambda x: x / 10)

Boucles

Il existe 2 types de boucles en Python:

  • for permettant de parcourir un objet itérable (c’est-à-dire qui implémente une fonction __iter__()).
  • while qui évalue une expression avant chaque itération.

for

for permet de parcourir des objets itérables. Cette instruction n’est pas utilisée avec une variable contenant l’index de la structure à parcourir comme ça c’est le cas pour d’autres langages. for est l’équivalent de foreach dans d’autres langages.

Par exemple:

values = [1, 3, 4, 9, 2, 5]
for value in values:
    print(value)
1
3
4
9
2
5

Dans cet exemple, values est une liste qui est un objet itérable comme les dictionnaires, set, tuple, etc…

range()

En Python, il n’y a pas d’équivalent des boucles for des autres langages. L’opérateur for en Python ne permet pas d’utiliser une variable index pour parcourir une structure. for s’utilise seulement avec un objet itérable. Ainsi, pour utiliser des index avec for, on peut utiliser la fonction range() qui permet de générer facilement un objet itérable.

Par exemple:

x = 0
for index in range(10):
    x += 10
    print("The value is {0}".format(x))

Dans cet exemple, range(10) produit une liste de 10 éléments commençant par 0.

D’autres surchages existent:

  • range(5, 10) permet d’itérer la suite 5, 6, 7, 8, 9
  • range(5, 10, 2) permet d’itérer la suite 5, 7, 9

5 est le début; 10 est la fin et 2 est l’incrément.

Le résultat de range() est un objet de type range qui est itérable et donc utilisable avec for.

On peut utiliser le constructeur list() pour extraire tous les objets générés par range():

>>> list(range(10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

La construction suivante est à éviter pour itérer dans une liste:

s = [0, 1, 4, 6, 13]
for i in range(len(s)):
    print(s[i])

Il faut itérer directement sur l’objet:

for v in s:
    print(v)

while

while est le même opérateur que dans les autres langages, il permet d’évaluer une condition avant d’itérer un bloc de code:

while <expression à évaluer>:
    <bloc exécuté si expression vraie>

Par exemple:

x = 0
while x < 10:
    print("Count is {0}".format(x))
    x += 1 # Comme pour "for" il faut incrémenter soi-même.

break et continue

break et continue ont la même signification que dans les autres langages, ils permettent:

  • break: de stopper une itération
  • continue: de passer directement à l’itération suivante.

Ils peuvent être utilisé avec for et while.

Par exemple, pour stopper l’exécution d’une boucle avec break:

values = [1, 3, 4, 9, 2, 5]
for value in values:
    if (value > 5):
        break
    print(value)
1
3
4

Exemple d’utilisation de continue pour ne pas exécuter une portion de code de la boucle et passer directement à l’itération suivante:

values = [1, 3, 4, 9, 2, 5]
for value in values:
    if value > 3 and value < 6:
        continue
    print(value)
1
3
9
2

Dans cet exemple, 4 et 5 ne sont pas affichés car l’exécution de continue empêche l’exécution de la ligne print(value).

Enumérateur

Un énumérateur est une fonction native de Python permettant d’avoir un compteur automatique s’appliquant sur un itérable. Cette fonction s’utilise avec un constructeur enumerate().

Par exemple avec une liste:

>>> items = ['One', 'Two', 'Three', 'Four', 'Five']
>>> enumerate_items = enumerate(items)
>>> type(enumerate_items)
enumerate

Le type de l’objet est enumerate.

Avec une boucle for:

for item in enumerate_items:
    print(item)
(0, 'One')
(1, 'Two')
(2, 'Three')
(3, 'Four')
(4, 'Five')

On obtient des tuples contenant un compteur et l’élément correpond à l’index du compteur dans l’itérable.

On peut effectuer une décomposition:

for index, item in enumerate_items:
    print(f"{index}:{item}")
0:One
1:Two
2:Three
3:Four
4:Five
L’énumérable ne doit être exécuté qu’une fois

Si l’enumerable a été exécuté une fois dans une boucle for. L’exécution suivante ne permet pas d’obtenir une nouvelle énumération.

Par exemple, si on exécute:

items = ['One', 'Two', 'Three', 'Four', 'Five']
enumerate_items = enumerate(items)
for item in enumerate_items:
    print(type(item))

On obtient bien l’énumération.

Si on réexécute:

for item in enumerate_items:
    print(type(item))

⇒ Pas de résultat

Il faut réinstancier l’énumérable pour obtenir une nouvelle énumération.

enumerate_items = enumerate(items)

Il existe une autre surchage de enumerate() permettant de préciser l’index de départ de l’énumération:

enumerate_items = enumerate(items, 2)
for index, item in enumerate_items:
    print(f"{index}:{item}")
2:One
3:Two
4:Three
5:Four
6:Five

Comprehensions

Une comprehension est une syntaxe permettant de créer facilement une suite pouvant être:

  • une liste,
  • un dictionnaire,
  • un set ou
  • un generator.

Par exemple, pour construire une list comprehension:

[expr(item) for item in iterable]

List comprehension

Une list comprehension permet de créer une liste, la syntaxe générale est:

[<expression> for <variable> in <iterable>]

Ou avec une condition:

[<expression> for <variable> in <iterable> if <condition>]

Par exemple, si on considère la liste suivante:

elements = ['One', 'Two', 'Three', 'Four', 'Five']

On peut utiliser une list comprehension pour créer une autre liste:

new_list = [len(element)] for element in elements]
print(new_list)
[3, 3, 5, 4, 4]

On peut utiliser plusieurs boucles dans une list comprehension, par exemple:

a = ['One', 'Two', 'Three']
b = [1, 2, 3]
new_list = [(x, y) for x in a for y in b]
print(new_list)
[('One', 1), ('One', 2), ('One', 3), ('Two', 1), ('Two', 2), ('Two', 3), ('Three', 1), ('Three', 2), ('Three', 3)]

On crée des listes de tuples avec (x, y).

En rajoutant une condition:

new_list = [(x, y) for x in a for y in b if a.index(x)==b.index(y)]
print(new_list)
[('One', 1), ('Two', 2), ('Three', 3)]

D’autres exemples:

[x ** 3 for x in range(10)]
[x ** 3 for x in range(10) if x % 2]
[(a, x) for x in range(3) for a in "abc"]

Sets comprehension

Permet de créer un set avec une comprehension:

{expr(item) for item in iterable}

Par exemple:

{x ** 2 for x in range(10)}
{0, 1, 4, 9, 16, 25, 36, 49, 64, 81}

Dictionary comprehension

Permet de créer un dictionnaire avec une comprehension.

La syntaxe générale est:

{key_expr: value_expr for item in iterable}

Par exemple:

items = [(1, 'One'), (2, 'Two'), (3, 'Three'), (4, 'Four'), (5, 'Five')]
{item[0]:item[1] for item in items}
{1: 'One', 2: 'Two', 3: 'Three', 4: 'Four', 5: 'Five'}
Dans le cas de clés dupliquées, les valeurs précédentes sont écrasées

Par exemple:

items = [(1, 'One'), (2, 'Two'), (2, 'Two duplicated'), (3, 'Three'), (3, 'Three duplicated')]
{item[0]:item[1] for item in items}
{1: 'One', 2: 'Two duplicated', 3: 'Three duplicated'}

On ne retrouve pas les valeurs issues des tuples (2, 'Two') et (3, 'Three').

Generators

Les generators permettent de générer des suites intégrables:

  • Ils sont évalués à la demande pour obtenir l’élément suivant (lazy evaluation)
  • Ils peuvent modéliser des suites infinies.
  • Les processus peuvent organiser dans un pipeline.
  • Un generator se définit comme une fonction traditionnelle avec le mot-clé yield.
  • Un generator est à usage unique. Si on définit un generator et qu’on l’utilise entièrement il faudra en créer un nouveau pour le réutiliser.

Par exemple:

def gen123():
    yield 1
    yield 2
    yield 3

g = gen123()

g est un generator:

  • 1er exécution next(g): 1
  • 2e exécution next(g): 2
  • 3e exécution next(g): 3
  • 4e exécution next(g): ERREUR

Fonctions generator avec état

  • Les generators permettent de reprendre l’exécution.
  • Ils maintiennent l’état des variables locales.
  • Ils sont évalués à la demande (lazy évaluation).
  • Des pipelines peuvent être implémentés en faisant des fonctions composées.

Par exemple:

def func1(arg):
    yield arg

def func2(arg):
    yield arg

En écrivant func1(func2(3)), on peut exécuter des espèces de pipeline.

On peut utiliser return pour arrêter une exécution avec yield:

Par exemple:

def take(count, iterable):
    counter = 0

    for item in iterable:
        if counter == count:
            return

        counter += 1
        yield item

Un autre avantage des generators est de permettre une exécution infinie.

Par exemple, si on fait une boucle infinie avec yield:

while True:
    yield...

On produit un objet itérable infini.

Generator comprehension (ou generator expression)

On peut définir un generator sous forme de comprehensions en utilisant la syntaxe suivante:

(expr(item) for item in iterable)

Cette syntaxe permet de fournir un generator (itérable).

Par exemple:

millions_squares = (x*x for x in range(1, 1000001))

Il suffit d’écrire avec une comprehension: sum(x*x for x in range(1, 1000001)) pour tirer partie des “generators”.

Il est aussi possible d’utiliser un prédicat:

(expr(item) for item in iterable if predicate(item))

Pour tester le generator, on peut utiliser list(<generator>), par exemple:

millions_squares = list((x*x for x in range(1, 1000001)))

Le module itertools permet de fournir des itérateurs:

  • count(start, step): commence à itérer à partir de start en ajoutant step à chaque itération. La boucle est infinie.
  • cycle(iterable): répète les valeurs de l’itérable indéfiniment.
  • repeat(val, num): répète num fois la valeur val.
  • islice(iterable, start, stop, step): renvoie les valeurs de l’itérable en commençant à l’index start, en terminant à l’index stop et en incrémentant l’index suivant la valeur step.

Pour utiliser ces fonctions il faut écrire:

from itertools import islice, count

Exceptions

Les exceptions permettent d’implémenter une gestion des erreurs en utilisant des blocs de code semblables aux try...catch.

Par exemple:

student = {
    { "name": "Mark", "student_id": 15304, "feedback": None }
}

try:
    last_name = student["last_name"]
except KeyError:
    # Cette erreur est lancée dans le cas d'une erreur KeyError.
    print("Error finding")

print("This code executes") # Ce code se trouve en dehors du try...catch et est donc toujours exécuté

persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }

try:
    unknown = persons['5']
except KeyError:
    # Cette erreur est lancée dans le cas d'une erreur KeyError.
    print("Error finding")

# Ce code se trouve en dehors du try...except et est donc toujours exécuté
print("This code executes")

La clé '5' du dictionnaire n’existe pas donc une exception KeyError est lancée et interceptée par except KeyError.

Dans cet exemple, seules les exceptions KeyError sont gérées. Les autres types d’exceptions ne sont pas gérées dans le bloc except.

Gestion de plusieurs types d’erreurs

On peut gérer plusieurs types d’exceptions en utilisant plusieurs blocs except.

Par exemple:

persons = { '1': 'Mark', '2': 'Elon', '3': 'Jeff', '4': 'Bill'  }

try:
    name = persons ['3']
    numbered_name = 3 + name
except KeyError:
    print("KeyError")
except TypeError:
    print("KO")   # Exécuté car on ne peut pas ajouté 3 à "Jeff"

On peut aussi utiliser un seul bloc except pour traiter plusieurs types d’exceptions:

try:
    name = persons ['3']
    numbered_name = 3 + name
except (KeyError, TypeError):
    print("KO")

Prendre en compte tous les types d’exceptions

Il faut utiliser un bloc:

except Exception:

Par exemple:

try:
    name = persons ['3']
    numbered_name = 3 + name
except Exception:
    print("KO")

Pour afficher l’erreur

Il faut utiliser la syntaxe:

except TypeError as error

Par exemple:

try:
    name = persons ['3']
    numbered_name = 3 + name
except TypeError as error:
    print(error) # L'erreur est affichée mais pas le numéro de ligne

Relancer une exception

Pour relancer une exception, il faut utiliser le mot-clé raise.

Par exemple:

try:
    name = persons ['3']
    numbered_name = 3 + name
except TypeError as error:
    print(error)
    raise   # Permet de relancer l'exception

Lancer une exception

Pas forcément dans un bloc try...except:

raise ValueError("<type de l'erreur>")

Quelques types d’exceptions courantes:

  • IndexError: index en dehors de l’intervalle d’une liste (out of range)
  • ValueError: objet avec le bon type mais avec une valeur qui n’est pas correcte.
  • KeyError: mauvaise clé dans un dictionnaire.
  • OSError: erreur avec l’API de l’OS (par exemple quand on lit un fichier)
  • TypeError: si on fait une opération avec des types incompatibles.

finally

Permet d’ajouter un bloc qui sera exécuté dans tous les cas c’est-à-dire dans le cas où une exception a été lancée ou non. Il suffit de prévoir un bloc finally après try...except:

Par exemple:

try:
    found_person = persons['5']
except KeyError:
    print("Error finding")
    found_person = 'unknown'
finally:
    print(found_person)

A l’exécution:

Error
finding
unknown

else

On peut utiliser une partie else dans un try...except...finally. La partie else sera exécutée quand il n’y a pas d’exception. Avec else, le bloc try...except devient:

try...except...else...finally

Par exemple:

try:
    found_person = persons['2']
except KeyError:
    print("Error finding")
    found_person = 'unknown'
else:
    print("Person found")
finally:
    print(found_person)

A l’exécution:

Person
found
Elon

Classe

On peut déclarer une classe en Python de cette façon:

class Student:
    pass

pass est un mot-clé valable pour les fonctions ou les classes pour dire de ne rien faire.

Instancier une classe

Pour instancier une classe:

student = Student()

Dans cet exemple:

  • studentest le nom de l’instance.
  • Student() est le nom de la classe.

Méthode membre

Une méthode membre peut être implémentée dans la classe de cette façon:

class Student:
    def add_student(self, name, student_id = 332):
        student = { "name": name, "student_id": student_id}
        students.append(student)

Une méthode membre doit avoir le 1er paramètre self lors de sa déclaration. Le mot-clé self peut aussi être utilisé pour désigner l’instance courante de la classe (équivalent de this).

Pour appeler une fonction membre, on peut utiliser les syntaxes suivantes qui sont équivalentes:
Si on instancie la classe de cette façon:
instance = Student()

  • instance.add_student('Alice', 34)
  • Student.add_student(instance, 'Alice', 34)

En Python 3, les classes n’héritant d’aucune classe héritent implicitement de la classe object.

Initializer

Un initializer est un espèce de constructeur. La différence avec un constructeur dans d’autres langages est que la classe est déjà construite quand l’initializer est exécuté.

L’initializer s’appelle toujours __init__() quelque soit le nom de la classe.

Par exemple:

class Student:
    def __init__(self, name, student_id = 332):
        student = {...}
        students.append(student)

Il ne peut y avoir qu’un seul initializer par classe.

L’initializer permet de déclarer et initialiser les données membres directement, par exemple si on écrit:

class Flight:
    def __init__(self, number):
        self._number = number

La variable _number n’a pas été déclarée avant. Cette seule déclaration suffit à déclarer la donnée membre _number.

En cas d’héritage:

  • Si l’initializer n’existe pas dans la classe fille, l’initializer de la classe parente est exécuté après instanciation de la classe fille.
  • Si un initializer existe dans la classe fille, l’initializer de la classe n’est pas implicitement exécuté. Il faut l’appeler explicitement avec super().__init__(<arguments>).

On peut utiliser la syntaxe du passage des paramètres par expansion pour éviter d’avoir à réécrire tous les arguments de l’initializer de la classe parente dans le constructeur de la classe fille.

Par exemple:

class Vehicule:
    def __init__(self, t_args, **d_args):
        ...

class Voiture(Vehicle):
    def __init__(self, nb_portes, t_args, **d_args):
        super(Voiture, self).__init__(t_args, **d_args)
        self.nb_portes = nb_portes

Attributs de classe et d’instance

En Python, on appelle:

  • Attributs de classe: des variables statiques d’une classe. Ces variables sont accessibles en utilisant la syntaxe <nom de la classe>.<nom variable>.
  • Attributs d’instances: des données membres d’un classe. Ces variables sont accessibles en utilisant la syntaxe <instance>.<nom variable> ou self.<nom variable>.

Par exemple:

class Example:
    variable = 5

print(Example.variable)   # 5 (attribut de classe)
Example.variable = 10
print(Example.variable)   # 10 (attribut de classe)

inst = Example()
print(inst.variable)      # 10 (valeur provenant de l'attribut de classe à l'initialisation)
inst.variable = 15        # modification de l'attribut d'instance
print(inst.variable)      # 15
print(Example.variable)   # 10 la variable de classe n'est pas modifiée.

Pour ajouter une variable membre à partir de l’initializer, par exemple:

class Student:
    def __init__(self, name, student_id = 332):
        self.name = name
        self.student_id = student_id
        students.append(self)

# self.<var> permet de définir une variable membre.

    def __str__(self):
        return "Student" + self.name

    def get_name_capitalize(self):
        return self.name.capitalize()

# capitalize() permet de remplacer la 1ère lettre par une majuscule.
Les attributs de classe sont partagés par toutes les instances

Si on affecte une valeur à un attribut de classe, l’attribut d’instance sera affecté s’il n’est pas initialisé dans l’initializer.

Par exemple, si on considère cette classe:

def Vehicle:
    couleur = 'blanc'

# Dans cette déclaration couleur correspond à un attribut de classe 
# et non à un attribut d'instance. Si on veut déclarer des attributs 
# de classe, il faut les initialiser dans l'initializer.

v1 = Vehicle()
v2 = Vehicle()
v1.couleur = 'rouge'      # Affectation de l'attribut d'instance
Vehicle.couleur = 'bleu'  # Affectation de l'attribut de classe
v3 = Vehicle()
v1.couleur rouge
v2.couleur bleu
v3.couleur bleu

Au moment de chercher la valeur d’un attribut, Python cherche dans cet ordre:

  • Existe-t-il un attribut d’instance ? Si oui c’est cette valeur qui est utilisée.
  • Existe-t-il un attribut de classe ? Si oui c’est cette valeur qui est utilisée.
  • Sinon une erreur est déclenchée

Il est possible d’affecter, de déclarer et d’initialiser des attributs à l’extérieur de la classe:

v1 = Vehicle()
v1.unknown = 6    # valide
# unknown est un attribut de classe

Définir une variable statique

Par exemple, si on considère la classe suivante:

class Student:
    school_name = "Springfield elementary"
    # Accesseur pour accéder à une variable membre

    def get_school_name(self):
        return self.school_name

On peut atteindre la variable statique sans instancier la classe:

print(Student.school_name)    # Pas d'instanciation

Héritage et polymorphisme

Dériver d’une classe

Pour dériver d’une classe, par exemple de la classe Student:

class HighSchoolStudent(Student):
    school_name = "Springfield High School"

La classe mère est Student.

En Python, l’héritage sert principalement pour éviter la duplication de code.

Surcharger une fonction

Pour surcharger une fonction, il n’y a pas de syntaxe particulière:

class HighSchoolStudent(Student):
    # Surcharge, pas de mot clé particulier
    def get_school_name(self) 
        return "This is the high school"

Accéder à une fonction dans la classe parente

On peut accéder à une fonction de la classe parente en utilisant super():

class HighSchoolStudent(Student):
    ...

    def get_name_capitalize(self):
        original_value = super().get_name_capitalize()
            return original_value + "HS"

super() est le mot clé pour atteindre la fonction de la classe parente. Il existe d’autres possibilités pour appeler la méthode de la classe parente.

Si on instance la classe fille:

highSchoolStudent = HighSchoolStudent()

alors:

  • super().get_name_capitalize() (syntaxe à privilégier) ou
  • Student.get_name_capitalize(highSchoolStudent) ou
  • super(HighSchoolStudent, self).get_name_capitalize()
Pas de modificateurs de portée

En Python, il n’existe pas de modificateurs de portée (private, protected), tout est publique.

Toutefois il existe des conventions:

  • Préfixe __ pour indiquer qu’une méthode ou un attribut est privé.
  • Préfixe _ pour indiquer qu’une méthode ou un attribut est protected.

On peut surcharger des méthodes particulières comme par exemple __str__ qui permet de convertir une instance d’une classe en chaîne de caractères. Pour surcharger cette fonction, il peut écrire dans la classe:

def __str__(self):
    return "Student"

Quelques autres méthodes particulière:

  • __bool__: permet de savoir si un objet est évalué comme valant True ou False dans une expression booléenne.
  • __del__: il s’agit du destructeur. Cette méthode est appelée quand l’objet est détruit en exécutant:
    del <instance>

    Ou:

    <instance> = None
    
  • __add__, __mul__, __sub__: permettent d’implémenter des comportements lorsque les opérateurs +, * et - sont utilisés entre 2 objets.

Héritage multiple

L’héritage multiple est possible en Python, il suffit d’utiliser la syntaxe:

class <nom classe>(<parent 1>, <parent 2>, ..., <parent n>)

En Python, le polymorphisme s’implique en ayant des classes avec les mêmes interfaces (au sens signature des méthodes car la notion d’interface n’existe pas en Python).

Lecture et écriture de fichiers

Ecrire un fichier

On peut utiliser les fonctions open(), write() et close() pour respectivement ouvrir, écrire et fermer un fichier sur le disque, par exemple:

def save_file(student):
    try:
        f = open("student.txt", "a")      # D'autres options sont possibles
        f.write("student.txt" + "\n")     #  permet d'écrire une ligne
        f.close()
    except Exception:
        print("Could not save file")

Les autres options possibles lors de l’ouverture de fichier:

  • "w": writing; écrase le fichier
  • "r": reading pour lire (valeur par défaut)
  • "x": création exclusive. Si le fichier existe déjà, une erreur est générée.
  • "rb": reading as binary
  • "wb": writing as binary
  • "a": append
  • "b": binary mode
  • "t": text mode
  • "+": ouvre un fichier pour mise à jour (lecture ou écriture)

Par défaut, l’encodage des fichiers texte en Python est fait en fonction du résultat de la fonction:

import sys
sys.getdefaultencoding()     # équivalent UTF-8

f = open('fileName.txt', mode = 'wt', encoding='utf-8')

Ainsi:

  • La partie encoding='utf-8' est optionnel.
  • mode='wt' correspond au mode write + text mode.
Pas de writeLine()

Il faut rajouter explicitement /n pour les retours à la ligne:

f.write("<chaine de caractères à rajouter>")

f.write("<chaine de caractères à rajouter>/n")   # avec le retour à la ligne.

/n pour le retour à la ligne peut être utilisé quelque soit l’OS. Pour Windows /n est remplacé par les bons caractères.

A la fin, il faut fermer en exécutant:

f.close()

Lire un fichier

Pour lire un fichier

def read_file():
    try:
        f = open("students.txt", "r")
        for student in f.readlines():
            add_student(student)
        f.close()
    except Exception:
        print("Could not read file")

Quelques fonctions pour lire le contenu d’un fichier après l’avoir ouvert:

g = open('wasteland.txt', mode='rt', encoding='utf-8')
  • g.read() permet de lire tout le fichier d’un coup.
  • g.seek(0) permet de placer le curseur à un certain point du fichier (0 signifie au début)
  • g.readline() lecture d’une ligne du fichier. La dernier caractère de la chaine contiendra \n le cas échéant (ce caractère peut ne pas être présent).
  • g.readlines() lit toutes les lignes d’un fichier et les range dans une liste.
  • g.close() ferme le fichier.

Utiliser des iterators

Lors de la lecture d’un fichier, on peut utiliser un iterator de cette façon:

f = open(...)
for line in f:
    print(line)
    # On peut aussi utiliser la syntaxe:
    sys.stout.write(line)
    f.close()

Pour utiliser sys.stout.write(line), il faut effectuer un import:

import sys

Ecrire à la suite d’un fichier texte

On utilise la syntaxe suivante:

f = open('wasteland.txt', mode='at', encoding='utf-8')

'at' pour append + text mode

Pour écrire des chaines ligne par ligne:

f.writelines(<liste contenant les chaînes de caractères>)

Il faut indiquer /n explicitement si on veut retourner à la ligne.

Ecrire un fichier binaire

f.tell() permet d’indiquer l’offset par rapport au début du fichier.

Pour écrire des bytes:

f.write(byte(...))
f.write(b'...')

Quelques générateurs de transformation vers les bytes.

Pour un entier (32 bits) vers des bytes:

  • i & 0xff ⇒ conversion du 1er octet de l’entier.
    i est un entier
    0xff correspond à 255
  • i >> 8 & 0xff ⇒ conversion du 2e octet de l’entier
    >> 8 permet de déplacer le curseur de 8 bits (1 octet) vers la droite
  • i >> 16 & 0xff ⇒ conversion du 3e octet de l’entier

Utiliser un bloc try…finally

L’utilisation d’un bloc try...finally lors de la lecture d’un fichier permet de bien fermer le fichier après lecture même dans le cas où une erreur survient, par exemple:

try:
    f = open(...)
    ...
finally:
    f.close()

Considérer un contexte de lecture avec des “with blocks”

Les “with blocks” permettent d’éviter d’avoir à exécuter f.close() à la fin des ouvertures de fichier.

Par exemple:

def read_lines(filename):
    with open(...) as f:
        return [int(line.strip()) for line in f]

Ce bloc est équivalent à using en C#.

Pour exécuter du code Python en ligne: https://colab.research.google.com.

Implémenter des tests dans une application Angular

Cet article fait partie de la série d’articles Angular from Scratch.


Le but de cet article est d’indiquer comment implémenter des tests unitaires dans une application Angular. Les tests peuvent porter sur du code dans la classe d’un composant, d’un service ou le rendu HTML à partir d’un template. On indiquera quelques méthodes pour mocker des objets, lancer des évènements ou vérifier que des exécutions se sont correctement déroulées.

Comment implémenter un test ?

Lorsqu’on crée une application Angular avec le CLI, il est directement possible d’exécuter les tests en utilisant Karma qui est un composant permettant d’exécuter des tests (i.e test-runner).

Par défaut, lorsqu’on crée un objet Angular avec le CLI, un fichier <nom de l'objet>.spec.ts est créé de façon à pouvoir implémenter des tests (voir Création d’un composant pour avoir un exemple).

Si on considère un composant nommé Example. On peut créer ce composant en exécutant la commande suivante:

ng g c Example

Parmi les fichiers créés se trouve un fichier nommé example.component.spec.ts. Ce fichier contient le squelette d’un test, par exemple:

import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ExampleComponent } from './example.component';

describe('ExampleComponent', () => {
  let component: ExampleComponent;
  let fixture: ComponentFixture<ExampleComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ ExampleComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(ExampleComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should create', () => {
    expect(component).toBeTruthy();
  });
});

Même si ce test n’effectue pas de tests pertinents, il peut être exécuté par Karma en exécutant la commande suivante:

ng test 

Un browser s’ouvre pour afficher une page similaire à celle-ci:

Dans l’exemple plus haut, le code de test utilise le framework Jasmine qui permet de faciliter l’implémentation de tests unitaires. Quelques détails sur ce code:

  • describe(): une suite de tests concernant un composant peut être implémentée à l’intérieur d’une fonction describe(). Cette fonction est exécutée par Karma au moment de l’exécution des tests. La syntaxe générale de cette méthode est:
    describe(<description du test>, <lambda comportant les tests>);
    

    Dans la lambda se trouve l’ensemble des fonctions permettant d’exécuter les tests. A l’intérieur de la lambda, les règles de portée de variable s’appliquent comme dans du code Javascript habituel (voir le scope des variables en Javascript).

  • beforeEach() et beforeEach(async)sont exécutées avant chaque exécution d’un test unitaire.
  • it() correspond à un test unitaire.
Comment lancer les tests avec Firefox à la place de Chrome ?

Par défaut, à l’exécution de la commande ng test, le browser Chrome est lancé. Pour lancer Firefox, il faut:

  1. Installer le package karma-firefox-launcher en exécutant:
    npm install karma-firefox-launcher --save-dev
    
  2. Configurer Karma en modifiant le fichier de configuration karma.conf.js et en rajoutant l’utilisation du plugin karma-firefox-launcher:
    module.exports = function (config) {
      config.set({
        basePath: '',
        frameworks: ['jasmine', '@angular-devkit/build-angular'],
        plugins: [
          require('karma-jasmine'),
          require('karma-chrome-launcher'),
          require('karma-firefox-launcher'),
          require('karma-jasmine-html-reporter'),
          require('karma-coverage'),
          require('@angular-devkit/build-angular/plugins/karma')
        ],
        client: {
          jasmine: {
          },
          clearContext: false 
        },
        jasmineHtmlReporter: {
          suppressAll: true 
        },
        coverageReporter: {
          // ...
        },
        // ...
      });
    };
    
    
  3. Indiquer au runner Karma de lancer Firefox plutôt que Chrome en modifiant la configuration browsers dans karma.conf.js:
    module.exports = function (config) {
      config.set({
        basePath: '',
        frameworks: ['jasmine', '@angular-devkit/build-angular'],
        plugins: [
          require('karma-jasmine'),
          require('karma-chrome-launcher'),
          require('karma-firefox-launcher'),
          require('karma-jasmine-html-reporter'),
          require('karma-coverage'),
          require('@angular-devkit/build-angular/plugins/karma')
        ],
        client: {
          jasmine: {
          },
          clearContext: false 
        },
        jasmineHtmlReporter: {
          suppressAll: true 
        },
        coverageReporter: {
          // ...
        },
        reporters: ['progress', 'kjhtml'],
        port: 9876,
        colors: true,
        logLevel: config.LOG_INFO,
        autoWatch: true,
        // browsers: ['Chrome'],
        browsers: ['Firefox'],
        singleRun: false,
        restartOnFileChange: true,
        files: [
          'src/script.js'
        ]
      });
    };
    

Comment débugger un test ?

On peut débugger un test de la même façon qu'à l'exécution (cf. Comment débugger une application Angular ?):

  1. Utiliser fdescribe() ou fit() pour n'exécuter qu'un seul test.
  2. Lancer Karma en exécutant ng test. Il est possible de débugger en pas à pas avec le browser en affichant les outils de développement:
  3. Pour afficher les outils de développement dans un browser:
    • Sous Firefox: on peut utiliser la raccourci [Maj] + [F7] (sous MacOS: [⌥] + [⌘] + [Z], sous Linux: [Ctrl] + [Maj] + [Z]) ou en allant dans le menu "Outils" ⇒ "Développement web" ⇒ "Débogueur".
    • Sous Chrome: utiliser le raccourci [F12] (sous MacOS: [⌥] + [⌘] + [I], sous Linux: [Ctrl] + [Maj] + [I]) puis cliquer sur l'onglet "Sources". A partir du menu, il faut aller dans "Afficher" ⇒ "Options pour les développeurs" ⇒ "Outils de développement".
  4. Dans l'onglet "Debugger" dans Firefox ou "Sources" dans Chrome, il faut déplier le nœud
    webpacksrcapp
    ou
    webpack://.srcapp
  5. Il est possible de placer des points d'arrêt en cliquant à coté de la ligne:
  6. On peut débugguer si on recharge la page avec [F5]:

    Ensuite, on peut taper:

    • [F8] pour relancer l'exécution jusqu'au prochain point d'arrêt,
    • [F10] pour exécuter la ligne de code sans entrer dans le corps des fonctions exécutées
    • [F11] pour exécuter la ligne de code en rentrant dans le corps des fonctions exécutées.

    Dans le débugger, on peut accéder à d'autres outils pour vérifier le contenu d'une variable, afficher la pile d'appels ou placer des points d'arrêts lorsque des évènements surviennent:

Implémentation des tests

Lorsqu'on lance ng test, tous les tests de l'application sont lancés. Par défaut, les tests sont implémentés dans des fichiers dont le nom est du type *.spec.ts. On peut modifier cette configuration dans le fichier tsconfig.spec.json (ce fichier permet de configurer les fichiers utilisés dans le cadre des tests):

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "./out-tsc/spec",
    "types": [
      "jasmine"
    ]
  },
  "files": [
    "src/test.ts",
    "src/polyfills.ts"
  ],
  "include": [
    "src/**/*.spec.ts",
    "src/**/*.d.ts"
  ]
}

describe()

Chaque fichier de test doit comporter une fonction describe() permettant d'implémenter une suite de tests unitaires, par exemple, pour une classe donnée.

Il est possible d'imbriquer les fonctions describe():

describe('main test suite', () => {
  describe('more precise test suite', () => {
    // ...
  });
});

Si on utilise this dans le code de describe(), c'est pour désigner le contexte global au sens javascript (cf. scope).

En imbriquant les méthodes describe(), on peut partager des variables, par exemple:

describe('Outer test suite', () => {
  let outerVar = 'outer';

  describe('Inner test suite 1', () => {
    let innerVar1 = 'inner';

    it('test 1', () => {      
      console.log(outerVar);
      console.log(innerVar1);
    });
  });

  describe('Inner test suite 2', () => {
    let innerVar2 = 'inner';

    it('test 2', () => {
      console.log(outerVar);
      console.log(innerVar2);
    });
  });
});

Pour éviter d'exécuter tous les tests et n'exécuter que les tests se trouvant dans un seul fichier *.spec.ts, il suffit de renommer la méthode describe() concernée en:

fdescribe('...', () => {
  // ...
});

Si une suite de tests est renommée en fdescribe(), seule cette suite sera exécutée.

Pour ne pas exécuter une suite de tests, il faut renommer la méthode describe() concernée en:

xdescribe('...', () => {
  // ...
});

it()

Cette méthode correspond à un test unitaire. Il peut y en avoir plusieurs dans une suite de tests implémentée avec describe():

describe('ExampleComponent', () => {
  it('test 1', () => {
    // ...
  });

  it('test 2', () => {
    // ...
  });

  it('test 3', () => {
    // ...
  });
});

Pour limiter l'exécution à une seule méthode it(), on peut la renommer en:

fit('...', () => {
  // ...
});

Pour ne pas exécuter le test dans une méthode it(), il faut la renommer en:

xit('...', () => {
  // ...
});

Setup et Teardown

Les méthodes suivantes permettent d'instancier, de configurer ou de détruire des objets utilisés lors de l'exécution des tests. Ces méthodes sont exécutées soit avant ou près tous les tests, soit avant ou après chaque test:

  • beforeEach(): permet d'exécuter un même code avant l'exécution de chaque test.
  • afterEach(): permet d'exécuter un même code après l'exécution de chaque test.
  • beforeAll(): permet d'exécuter du code avant d'exécuter les tests dans la méthode describe().
  • afterAll(): permet d'exécuter du code après l'exécution des tests dans la méthode describe().

Ces méthodes sont à implémenter à l'intérieur d'une méthode describe().

Stopper l'exécution ou faire échouer un test

Lors d'un appel à it(), fit() ou xit(), on peut exécuter les méthodes suivantes:

  • pending(): permet de marquer un test en attente. L'exécution ne mènera pas à une erreur quelque soit les résultats du test.
  • fail(): indique une erreur lors de l'exécution du test.

Vérification des résultats (espions)

Pour vérifier le contenu d'objets en les comparant avec une valeur attendue, on peut utiliser la méthode expect():

  • expect(<objet à tester>).toBeTruthy(): vérifier qu'une variable contient une valeur. Pour être plus précis, cet opérateur teste si un objet est égal à true en utilisant l'opérateur type coercion !!. Il ne faut confondre toBeTruthy() avec toBeUndefined() ou toBeNull().
  • expect(<objet à tester>).toBeUndefined(): pour comparer si un objet est égal à Undefined.
  • expect(<objet à tester>).toBeNull(): pour comparer si un objet est égal à Null.
  • expect(<objet à tester>).toBe(<valeur attendue>): pour vérifier si des objets ont des valeurs égales (pour les types primitifs) ou sont les mêmes. La comparaison utilisée est ===.
  • expect(<objet à tester>).not.toBe(<valeur non attendue>): pour vérifier qu'une variable ne correspond pas à un autre objet. La comparaison utilisée est !==.
  • expect(<objet à tester>).toEqual(<valeur attendue>): pour comparer par rapport à une valeur attendue. La comparaison est effectuée par valeur, si des objets différents ont les mêmes valeurs alors toEqual() renverra true. Il ne faut confondre cette fonction avec toBe() qui renvoie false si des objets sont de même valeur mais différents en mémoire.
  • expect(<objet à tester>).toBeTrue(): pour comparer si une valeur est true. La comparaison utilisée est === true.
  • expect(<objet à tester>).toBeLessThan(<valeur numérique>): pour vérifier si une valeur est inférieure à une valeur particulière.
  • expect(<objet à tester>).toBeGreaterThan(<valeur numérique>): pour vérifier si une valeur est supérieure à une valeur particulière.

Pour vérifier que des fonctions d'un objet ont été appelées:

  • Appeler spyOn(<objet à espionner>, '<nom de la fonction de l'objet à vérifier>'); pour indiquer à Jasmine qu'on souhaite espionner la méthode d'un objet.
  • Pour effectuer les vérifications:
    • Qu'une fonction a été exécutée une fois: expect(<fonction à vérifier sous la forme obj.function>).toHaveBeenCalled();
    • Qu'une fonction a été exécutée un certain nombre de fois: expect(<...>).toHaveBeenCalledTimes(<nombre d'appels attendu>);
    • Qu'une fonction a été appelée avec des arguments particuliers: expect(<...>).toHaveBeenCalledWith(<arguments attendus>);
  • Pour indiquer n'importe quel argument correspondant à un type particulier:
    jasmine.any(<type attendu>)
    

    Par exemple:
    expect(<objet à espionner>).toHaveBeenCalledWith(jasmine.any(Number)); permet de tester un argument de type Number.

  • Pour accéder aux informations stockées lorsqu'une fonction est espionnée:
    obj.<fonction à espionner>.calls
    

Créer un mock

Pour créer un espion ou un objet mock (pour lequel on peut implémenter un comportement particulier):

instanceObj = jasmine.createSpyObj('<nom de la variable>', ['<fonction à définir dans l'espion>', ...]);

Ou

instanceObj = jasmine.createSpyObj<type objet espion>('<nom de la variable>', ['<fonction à définir dans l'espion>', ...]);
  

Le but d'un mock est de l'utiliser en tant qu'argument de fonction ou de constructeur d'une classe de façon à éviter d'utiliser l'implémentation réelle dont l'utilisateur peut être plus contraignante dans le cadre de tests.

Vérifier qu'une fonction existe dans un espion:

expect(<objet espion>.<fonction>).toBeDefined();

Par exemple, pour implémenter un comportement particulier pour le mock itemRepositoryService:

itemRepositoryService = jasmine.createSpyObj('itemRepositoryService', [ 'addNewItem', 'findItemFromId', 'findItem' ]);
// Configurer un comportement particulier dans le mock
itemRepositoryService.addNewItem.and.returnValue(5);
itemRepositoryService.findItemFromId.and.returnValue(undefined);
itemRepositoryService.findItem.and.returnValue(undefined);

addNewItem, findItemFromId, findItem sont des fonctions de l'objet itemRepositoryService.

Utilisation d'un TestBed

Un "TestBed" (i.e. banc d'essai) permet de tester un composant de façon plus complète en donnant la possibilité d'interagir avec d'autres objets:

  • Permettre l'injection de services dans le composant.
  • Tester le composant avec des composants enfants.
  • Tester le code de la classe du composant avec son template.

L'objet "TestBed" (dans @angular/core/testing) s'utilise sous forme d'un singleton:

  • TestBed.configureTestingModule(<configuration d'un module>): permet de configurer le "TestBed" avec les paramètres d'un module.
  • TestBed.createComponent(<type du composant à créer>): permet de créer un objet ComponentFixture pour tester un composant avec l'injection de dépendances.
  • TestBed.inject(<type de l'object à injecter>): permet d'injecter un objet dans la configuration du "TestBed".

Par exemple si on considère le composant suivant:

@Component({
  ...
})
export class FirstComponent {
  constructor(public itemService: ItemService) {}
}

Le service ItemService est:

@Injectable({
  providedIn: 'root'
})
export class ItemService {
  constructor() { }
}

Avec l'injecteur suivant, le service est injecté au niveau de l'application (voir Injection de dépendances dans une application Angular pour plus de détails):

@Injectable({
  providedIn: 'root'
})

Ainsi pour injecter le service ItemService dans le composant lors des tests, on peut utiliser le "TestBed" de cette façon:

describe('FirstComponent', () => {
  let component: FirstComponent;
  let fixture: ComponentFixture<FirstComponent>;

  beforeEach(() => {
    fixture = TestBed.createComponent(FirstComponent);
    component = fixture.componentInstance;
  });

  it('should create', () => {
    // La variable component contient une instance du composant FirstComponent
  });
})

Si l'injecteur du service se trouve au niveau du module:

@Injectable()
export class ItemService {}
 
@NgModule({
  providers: [ ItemService ]
})
export class CustomModule {}

ou au niveau du composant:

@Component({
  ...
  providers: [ ItemService ]
})
export class FirstComponent {
  constructor(public itemService: ItemService) {
  }
}

On peut imiter la configuration de l'injection avec le TestBed:

describe('FirstComponent', () => {
  let component: FirstComponent;
  let fixture: ComponentFixture<FirstComponent>;

  beforeEach(() => {
    // Configuration similaire à celle dans un module
    TestBed.configureTestingModule({
      declarations: [FirstComponent],
      providers: [ItemService]
    });

    fixture = TestBed.createComponent(FirstComponent);
    component = fixture.componentInstance;
  });

  it('should create', () => {
    // La variable component contient une instance du composant FirstComponent
  });
});

NO_ERRORS_SCHEMA/CUSTOM_ELEMENTS_SCHEMA

Si le template d'un composant comporte une erreur, cette erreur peut faire échouer un test. Dans les cas où on ne souhaite tester que la classe du composant, l'échec du test dû aux problèmes dans le template peut empêcher au test d'aboutir. Une solution est de configurer le module dans le "TestBed" avec NO_ERRORS_SCHEMA ou CUSTOM_ELEMENTS_SCHEMA. Ces éléments de paramétrage permettent de définir un schéma dans un module qui autorise des éléments HTML ou des propriétés avec des noms particuliers:

  • NO_ERRORS_SCHEMA: autorise n'importe quel nom d'éléments ou de propriétés. Ce paramétrage doit être utilisé avec précaution puisqu'il cache toutes les erreurs dans le template.
  • CUSTOM_ELEMENTS_SCHEMA: autorise les éléments ou les propriétés inconnus s'ils contiennent le caractère "-".

Par exemple, si on considère le composant suivant:

Template
<p>simple works!</p>
<unknown></unknown>
Classe du composant
@Component({
  selector: 'app-simple',
  templateUrl: './simple.component.html'
})
export class SimpleComponent {}

Avec le test suivant (implémentation par défaut):

describe('SimpleComponent', () => {
  let component: SimpleComponent;
  let fixture: ComponentFixture<SimpleComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      declarations: [ SimpleComponent ]
    })
    .compileComponents();
  });

  beforeEach(() => {
    fixture = TestBed.createComponent(SimpleComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  fit('should create', () => {
    expect(component).toBeTruthy();
  });
});

Une erreur se produira à cause de l'élément "unknown" qui ne correspond pas à un élément connu:

ERROR: 'NG0304: 'unknown' is not a known element:
1. If 'unknown' is an Angular component, then verify that it is part of this module.
2. To allow any element add 'NO_ERRORS_SCHEMA' to the '@NgModule.schemas' of this component.'

On peut configurer le module avec 'NO_ERRORS_SCHEMA':

import { NO_ERRORS_SCHEMA } from '@angular/core';
...

beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [ SimpleComponent ],
    schemas: [ NO_ERRORS_SCHEMA ]
  })
  .compileComponents();
});

L'erreur ne se produit plus à l'exécution du test:

✔ Browser application bundle generation complete.
Firefox 78.0 (Linux aarch64): Executed 1 of 8 (skipped 7) SUCCESS (0.067 secs / 0.029 secs)
TOTAL: 1 SUCCESS

Si on remplace NO_ERRORS_SCHEMA par CUSTOM_ELEMENTS_SCHEMA:

beforeEach(async () => {
  await TestBed.configureTestingModule({
    declarations: [ SimpleComponent ],
    schemas: [ CUSTOM_ELEMENTS_SCHEMA ]
  })
  .compileComponents();
});

L'erreur se produit de nouveau. Si on modifie le nom de l'élément dans le template du composant en introduisant le caractère "-", l'erreur ne se produit plus:

<p>simple works!</p>
<un-known></un-known>

Tester le rendu HTML

On peut tester le contenu du code HTML rendu par le template du composant. Le contenu HTML est requêtable en Javascript par l'intermédiaire du DOM de la même façon qu'une page HTML classique. La différence est qu'il faut prendre en compte les évènements Angular pour effectuer les requêtes au bon moment (voir Fonctionnement de la détection de changement pour plus de détails).

Ainsi pour que les bindings du template soient exécutés, il faut déclencher la détection de changements en exécutant la ligne suivante avant d'effectuer le test:

fixture.detectChanges();

La détection de changements n'est pas nécessaire si le contenu statique du template est requêté.

Le requêtage du code HTML peut se faire en utilisant les fonctions Javascript element.querySelector() ou element.querySelectorAll().

Par exemple pour détecter un lien dans le code HTML suivant:

<p id="itemCountLabel">Item count: {{itemCount}}</p>
<p id="itemNameLabel">Item name: {{itemName}}</p>
<p id="itemIdLabel">Item ID: {{itemId}}</p>

On peut implémenter un test de cette façon:

describe('FirstComponent', () => {
  let component: FirstComponent;
  let fixture: ComponentFixture<FirstComponent>;

  beforeEach(() => {
    fixture = TestBed.createComponent(FirstComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  it('should display item name', () => {
    expect(fixture.nativeElement.querySelector('#itemNameLabel').textContent).toContain('Item name: element1');
  });
});

fixture.nativeElement est de type ElementRef et permet d'accéder à l'objet du DOM.

Syntaxe à utiliser avec querySelector()

Pour effectuer les requêtes avec element.querySelector() ou element.querySelectorAll(), il faut utiliser une syntaxe particulière:

Type de l'élément requêté Syntaxe Exemple
Type d'un élément HTML "<type de l'élément HTML>" Pour requêter <p></p>:
element.querySelector("p")
ID d'un élément "#<Id de l'élément>" Pour requêter <p id="itemId">Text content</p>:
element.querySelector("#itemId")
Classe CSS utilisée par un élément ".<classe CSS sur un élément HTML>" Pour requêter <p class="titleStyle"></p>:
element.querySelector(".titleStyle")
Elément ayant un attribut particulier "<élément HTML>[<attribut attendu>]" Pour requêter <p data-src></p>:
element.querySelector("p[data-src]")
Chercher suivant la valeur d'un attribut "<élément HTML>[<attribut attendu>='<valeur de l'attribut>']" Pour requêter <p data-active="1"></p>:
element.querySelector("p[data-active='1']")

En espaçant plusieurs requêtes avec un espace, on peut indiquer des conditions d'imbrications d'éléments.

Par exemple, pour requêter un élément p se trouvant dans undiv, on pourra exécuter:

element.querySelector("div p");

En espaçant avec une virgules, l'opérateur de requête est le "ou" logique.

Par exemple, pour requêter les objets p utilisant la classe CSS itemClass1 et itemClass2:

element.querySelector("p.itemClass1, p.itemClass2");

Enfin, il est possible de cumuler les conditions en requêtant suivant plusieurs critères, par exemple:
"p.itemClass1.itemClass2" pour requêter un élément p utilisant les classes CSS itemClass1 et itemClass2.

debugElement vs nativeElement

On peut accéder à l'objet brut du DOM avec la propriété ComponentFixture<T>.nativeElement. La propriété ComponentFixture<T>.debugElement permet d'encapsuler l'objet du DOM et de l'enrichir dans un objet DebugElement.

debugElement permet de requêter dans un arbre d'éléments de type DebugElement ou DebugNode (DebugElement dérive de DebugNode):

DebugElement expose des accesseurs:

  • properties pour accéder aux propriétés des éléments utilisées dans le cadre de bindings.
  • attributes pour accéder aux attributs HTML.
  • classes pour obtenir les classes CSS.
  • styles pour accéder aux styles définis de façon inline dans un élément HTML.
  • childNodes pour obtenir un tableau de DebugNode contenant les éléments enfant.
  • children pour obtenir les éléments enfants directs sous forme d'un tableau de DebugElement.

Comme pour querySelector, debugElement peut être utilisé pour effectuer des requêtes parmi les objets du DOM:

  • query(): permet d'obtenir le premier élément satisfaisant la condition de la requête.
  • queryAll(): retourne une liste d'éléments satisfaisant la condition de la requête.
  • queryAllNodes(): renvoie une liste d'objets de type DebugNode permettant de circuler dans l'arbre des objets.

La condition de la requête peut être indiquée avec un prédicat satisfaisant l'interface :

interface Predicate<T> {
  (value: T): boolean
}

On peut s'aider de By pour définir ce prédicat:

  • By.all(): tous les éléments testés répondent à la condition.
  • By.css(): permet d'indiquer une condition en testant un sélecteur CSS. Sélecteur CSS ne veut pas dire qu'on ne peut requêter que par les classes CSS. On peut effectuer des requêtes par:
    • Elément HTML: par exemple By.css('h1') pour requêter un élément <h1></h1>; By.css('button') pour requêter un bouton <button></button> etc...
    • Une classe CSS: par exemple By.css('.box') pour requêter la classe CSS box.
    • Un élément avec un identifiant: par exemple By.css('#elementId') pour requêter un élément ayant l'ID elementId.
  • By.directive(): pour filtrer des directives en indiquant explicitement leur type. Cette condition peut être utilisée pour tester des composants enfant (puisqu'un composant est un cas particulier de directive).

On peut aussi définir des prédicats particuliers en utilisant une lambda:

import { DebugElement } from '@angular/core';
// ...

fixture.debugElement.query((debugElement: DebugElement) => { 
  return debugElement.name === 'li'; 
})

Tester le rendu d'un évènement sur le template

En plus de tester le contenu statique du template d'un composant, on peut vérifier le rendu lorsqu'un évènement survient.

Par exemple, si on considère le composant suivant comportant:

  • une zone input pour indiquer le nom de l'item à rajouter et
  • un bouton: le click sur le bouton permet de déclencher la méthode addItem() et de vider le contenu de la zone input.

L'implémentation du composant est:

Template
<div>
  <label>Item name is: 
    <input #content/>
  </label>
  <button (click)="addItem(content.value); content.value=''">Add new item</button>
</div>
Classe du composant
@Component({
  selector: 'app-example',
  templateUrl: './example.component.html'
})
export class ExampleComponent {
  public Items: Array<Item>;

  constructor(private itemService: ItemService) {   
  }

  addItem(itemName: string): void {
    this.itemService.addItem(itemName);
  }
}

On souhaite implémenter un test pour vérifier qu'en cas de click sur le bouton:

  • La méthode addItem() est déclenchée avec le bon argument
  • Le contenu de la zone input est vidé.

L'implémentation du test pourrait être:

describe('ExampleComponent', () => {
  let fixture: ComponentFixture<ExampleComponent>;
  let itemService: any;

  beforeEach(() => {
    itemService = jasmine.createSpyObj(['addItem']);

    TestBed.configureTestingModule({
      declarations: [ ExampleComponent, ItemComponent ],
      providers: [
        { provide: ItemService, useValue: itemService }
      ]
    })

    fixture = TestBed.createComponent(ExampleComponent);
    fixture.detectChanges();
  });

  fit('when triggering button click then item shall be added and input content shall be cleared', () => {    
    // On requête les éléments input et button
    let inputElement = fixture.debugElement.query(By.css('input'));
    let buttonElement = fixture.debugElement.query(By.css('button'));

    expect(inputElement).toBeTruthy();
    expect(buttonElement).toBeTruthy();

    // On entre une valeur dans la zone input
    let newItemName = 'New item';
    inputElement.nativeElement.value = newItemName;

    // On indique à Jasmine qu'on veux surveiller la méthode addItem() du composant
    spyOn(fixture.componentInstance, 'addItem');

    // On déclenche un click sur le bouton
    buttonElement.triggerEventHandler('click', null);
    
    // On déclenche la détection de changement pour que les bindings soient exécutés
    fixture.detectChanges();

    // On vérifie que la méthode addItem() a été appelée et que le contenu du l'input est vide
    expect(fixture.componentInstance.addItem).toHaveBeenCalledOnceWith(newItemName);
    expect(inputElement.nativeElement.value).toBe('');
  });
});

Mocker les éléments ou attributs entraînant des erreurs dans le template

Certains éléments ou attributs sur des éléments dans le template peuvent entraîner des erreurs dans les tests.

Par exemple si un test est exécuté avec le template suivant:

<unknown></unknown>

On obtiendra une erreur:

ERROR: 'NG0304: 'unknown' is not a known element:
1. If 'unknown' is an Angular component, then verify that it is part of this module.
2. To allow any element add 'NO_ERRORS_SCHEMA' to the '@NgModule.schemas' of this component.'

On l'a vu précédemment, on peut corriger ce problème en utilisant NO_ERRORS_SCHEMA ou CUSTOM_ELEMENTS_SCHEMA. Le gros problème de cette solution est qu'elle empêche de voir les autres problèmes dans le template.

Une solution est de mocker l'élément inconnu en utilisant une directive. L'intérêt de la directive est qu'elle n'a pas de template par rapport à un composant, elle est donc plus simple à implémenter. Ensuite, il suffit de paramétrer différemment le paramètre selector dans le cas d'un élément ou d'un attribut.

Par exemple, si on crée la directive suivante:

@Directive({
  selector: 'unknown'
})
class UnknownDirective {
}

On peut l'ajouter dans la configuration du TestBed:

describe('ExampleComponent', () => {
  let fixture: ComponentFixture<ExampleComponent>;

  @Directive({
    selector: 'unknown'
  })
  class UnknownDirective {
  }

  beforeEach(() => {
    TestBed.configureTestingModule({
      declarations: [ ExampleComponent, UnknownDirective ]
    });

    fixture = TestBed.createComponent(ExampleComponent);
    fixture.detectChanges();
  });

  it('should create', () => {    
       // ...
  });
});

L'exécution du test ne produira plus l'erreur.
Dans le cas d'un attribut, par exemple:

<span unknown></span>

il suffit de paramétrer le selector de la directive:

@Directive({
    selector: '[unknown]'
})
class UnknownDirective {
}

Si l'attribut a une valeur, il faut créer un paramètre d'entrée @Input() dans la directive.

L'attribut routerLink permet de faire appel au router pour passer d'une vue à l'autre.
Son utilisation dans le template peut mener à des erreurs lors de l'exécution des tests, par exemple si on considère le composant suivant:

Template
<div *ngFor="let item of Items"> 
  {{'ID: ' + item.id + '/Name: ' + item.name}} 
  <a routerLink="/detail/{{item.id}}"> - Item {{item.id}}</a>
</div> 
Classe du composant
@Component({
  selector: 'example',
  templateUrl: './example.component.html'
})
export class ExampleComponent {
  public Items: Array<Item>;

  constructor(private itemService: ItemService) { 
    this.Items = itemService.getItems();
  }

  addItem(itemName: string): void {
    this.itemService.addItem(itemName);
  }
}

On peut avoir des erreurs du type:

ERROR: 'NG0303: Can't bind to 'routerLink' since it isn't a known property of 'a'.'

Pour éviter cette erreur, on peut créer une directive (comme indiqué précédemment) avec pour paramètre selector [routerLink] et un paramètre @Input() nommé routerLink:

@Directive({
  selector: '[routerLink]',
  host: { '(click)': 'onClick()'}
})
class RouterLinkDirectiveStub {
  @Input('routerLink') routerLinkValue: any;
  linkValue: any = null;

  onClick() {
    this.linkValue = this.routerLinkValue;
  }
}

On s'abonne à l'évènement click pour affecter le membre linkValue si un click est effectué.

On implémente un test en ajoutant la directive RouteLinkDirectiveStub dans le TestBed:

describe('ExampleComponent', () => {
  let fixture: ComponentFixture<ExampleComponent>;
  let itemService: any;

  // Implémentation de la directive
  @Directive({
    selector: '[routerLink]',
    host: { '(click)': 'onClick()'}
  })
  class RouterLinkDirectiveStub {
    @Input('routerLink') routerLinkValue: any;
    linkValue: any = null;

    onClick() {
      this.linkValue = this.routerLinkValue;
    }
  }

  beforeEach(() => {
    // Configuration du service injecté dans ExampleComponent
    itemService = jasmine.createSpyObj(['addItem', 'getItems']);
    itemService.getItems.and.returnValue([
      { id: 0, name: 'item 0'},
      { id: 1, name: 'item 1'},
      { id: 2, name: 'item 2'},
      { id: 3, name: 'item 3'},
    ]);

    // Configuration du TestBed
    TestBed.configureTestingModule({
      declarations: [ ExampleComponent, RouterLinkDirectiveStub ],
      providers: [
        { provide: ItemService, useValue: itemService }
      ]
    })

    fixture = TestBed.createComponent(ExampleComponent);
    fixture.detectChanges();
  });

  fit('when clicking routerLink then link shall be properly set', () => {    
    // On requête le template pour récupérer les éléments a et les directives RouterLinkDirectiveStub
    let linkElements = fixture.debugElement.queryAll(By.css('a'));
    let routerLinkStubs = fixture.debugElement.queryAll(By.directive(RouterLinkDirectiveStub));    

    expect(linkElements.length).toBe(4);
    expect(routerLinkStubs.length).toBe(4);
    
    // On ne teste que le premier élément
    let firstLinkElement = linkElements[0];
    let firstRouterLinkStub = routerLinkStubs[0];
    expect(firstRouterLinkStub).toBeTruthy();

    // On déclenche un click sur le lien
    firstLinkElement.triggerEventHandler('click', null);

    // On vérifie que la route est correcte après click sur le lien
    expect(firstRouterLinkStub.injector.get(RouterLinkDirectiveStub).linkValue).toBe('/detail/0');
  });
});

Tester un composant avec un composant enfant

Dans le cas où un composant contient un ou plusieurs composants enfant, dans un test du composant parent il peut être difficile d'utiliser l'implémentation réelle des composants enfant. Par exemple, si le template des composants enfant provoque des erreurs ou si les composants enfant nécessitent des dépendances difficiles à mocker.
Une solution est de ne pas utiliser l'implémentation réelle du composant enfant mais d'utiliser un fake plus simple implémenté seulement dans le test.

Par exemple, si considère le composant Parent et le composant Child, Child étant un composant enfant de Parent:

  • Le composant enfant Child:
    Template
    {{'ID: ' + ItemToDisplay?.id + ' - Name: ' + ItemToDisplay?.name}} 
    
    Classe du composant
    @Component({
      selector: 'child',
      templateUrl: './child.component.html'
    })
    export class ChildComponent implements AfterContentInit {
      @Input() itemId!: number;
      ItemToDisplay: Item | undefined;
    
      constructor(private itemRepositoryService: ItemRepositoryService) {  }
    
      ngAfterContentInit(): void {
        this.ItemToDisplay = this.itemRepositoryService.findItemFromId(this.itemId);
      }
    }
    
  • Le composant Parent:
    Template
    <p id="itemCount">Items (count: {{Items.length}}):</p>
    <div *ngFor="let item of Items"> 
      <child [itemId]=item.id></child>
    </div> 
    
    Classe du composant
    @Component({
      selector: 'parent',
      templateUrl: './parent.component.html'
    })
    export class ParentComponent {
      public Items: Array<Item>;
    
      constructor(private itemService: ItemService) { 
        this.Items = itemService.getItems();
      }
    }
    

Le composant Parent possède une dépendance vers le service ItemService et le composant Child possède une dépendance vers ItemRepositoryService.

L'implémentation d'un test sur le composant Parent pourrait être:

describe('ParentComponent', () => {
  let component: ParentComponent;
  let fixture: ComponentFixture<ParentComponent>;
  let itemService: any;

  beforeEach(() => {
    // Configuration du service injecté dans le composant parent
    itemService = jasmine.createSpyObj(['addItem', 'getItems']);
    itemService.getItems.and.returnValue([
      { id: 0, name: 'item 0'},
      { id: 1, name: 'item 1'},
      { id: 2, name: 'item 2'},
      { id: 3, name: 'item 3'},
    ]);

    // Configuration du TestBed
    TestBed.configureTestingModule({
      declarations: [ ParentComponent, ChildComponent ],
      providers: [
        { provide: ItemService, useValue: itemService }
      ]
    });

    fixture = TestBed.createComponent(ParentComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  fit('should properly count items', () => {
    expect(fixture.nativeElement.querySelector('#itemCount').textContent).toEqual('Items (count: 4):');
  });
});

Ce test ne fonctionne pas car la dépendance vers ItemRepositoryService du composant Child n'est pas assurée. Si on considère l'hypothèse qu'on souhaite éviter d'utiliser l'implémentation réelle du composant Child car la dépendance ItemRepositoryService est difficile à mocker. On implémente un fake du composant enfant Child dans le test puis on déclare le fake dans le "TestBed":

describe('ParentComponent', () => {
  let component: ParentComponent;
  let fixture: ComponentFixture<ParentComponent>;
  let itemService: any;

  // Composant enfant "fake"
  @Component({
    selector: 'child',  // Même paramètre selector que l'implémentation réelle
    template: '<div></div>'
  })
  class FakeChildComponent {
  }

  beforeEach(() => {
    // Configuration du service injecté dans le composant parent
    itemService = jasmine.createSpyObj(['addItem', 'getItems']);
    itemService.getItems.and.returnValue([
      { id: 0, name: 'item 0'},
      { id: 1, name: 'item 1'},
      { id: 2, name: 'item 2'},
      { id: 3, name: 'item 3'},
    ]);

    // Configuration du TestBed
    TestBed.configureTestingModule({
      declarations: [ ParentComponent, FakeChildComponent ],
      providers: [
        { provide: ItemService, useValue: itemService }
      ]
    });

    fixture = TestBed.createComponent(ParentComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  fit('should properly count items', () => {
    expect(fixture.nativeElement.querySelector('#itemCount').textContent).toEqual('Items (count: 4):');
  });
});

Dans cet exemple, le composant FakeChildComponent n'a pas de dépendances contrairement au composant enfant d'origine.

Effectuer une recherche dans une liste d'éléments

L'exemple précédent montrait comment rechercher parmi les éléments du template en utilisant un ID. L'inconvénient de cette solution est qu'elle nécessite de modifier le template pour y introduire un identifiant utilisable par les tests. Une autre solution est de chercher parmi les éléments du template en effectuant des requêtes avec:

Par exemple, si dans l'exemple plus haut, on effectue parmi les éléments de type li, on peut utiliser la méthode debugElement.queryAll():

expect(fixture.debugElement.queryAll(By.css('li')).length).toBe(4);

Pour plus de détails sur la façon d'utiliser debugElement.queryAll() et de définir des prédicats avec By, voir debugElement vs nativeElement plus haut.

Lancer des évènements dans un composant enfant

Si un composant enfant expose un paramètre @Output() (voir @Output() + EventEmitter pour plus de détails) pour permettre un event binding avec le composant parent, dans un test on peut déclencher un évènement dans le composant enfant et vérifier sa propagation dans le composant parent.

Si on considère un composant parent contenant plusieurs instances d'un composant enfant. Le composant enfant possède des paramètres @Input() (paramètre d'entrée) et @Output() (évènement de sortie). Un event binding est implémenté entre le paramètre @Output() du composant enfant et une fonction du composant parent.

L'implémentation est du type:

  • Composant enfant:
    Template
    {{'ID: ' + ItemToDisplay?.id + ' - Name: ' + ItemToDisplay?.name}} 
    <button (click)='deleteItem()'>Delete Item</button>
    
    Classe du composant
    @Component({
      selector: 'child',
      templateUrl: './child.component.html'
    })
    export class ChildComponent implements AfterContentInit {
      // Paramètre d'entrée
      @Input() itemId!: number;
      // Evènement de sortie
      @Output() itemDeleted: EventEmitter<number>= new EventEmitter<number>();
      ItemToDisplay: Item | undefined;
    
      constructor(private itemRepositoryService: ItemRepositoryService) { }
    
      ngAfterContentInit(): void {
        this.ItemToDisplay = this.itemRepositoryService.findItemFromId(this.itemId);
      }
    
      deleteItem(): void {
        this.itemDeleted.emit(this.itemId);
      }
    }
    

    Un click sur le bouton déclenche la méthode deleteItem() qui émet l'évènement itemDeleted.

  • Composant parent:
    Template
    <p id="itemCount">Items (count: {{Items.length}}):</p>
    <ul>
      <div *ngFor="let item of Items"> 
        <li><child [itemId]=item.id (itemDeleted)='deleteItem($event)'></child></li>
      </div>  
    </ul>
    
    Classe du composant
    @Component({
      selector: 'parent',
      templateUrl: './parent.component.html'
    })
    export class ParentComponent {
      public Items: Array<Item>;
    
      constructor(private itemService: ItemService) { 
        this.Items = itemService.getItems();
      }
    
      deleteItem(itemIdToDelete: number): void {
        if (!this.itemService.deleteItem(itemIdToDelete))
          console.error(`Item ${itemIdToDelete} has not been deleted.`);
      }
    }
    

On cherche à implémenter un test qui:

  • déclenche l'évènement itemDeleted (coté composant enfant) et
  • vérifie que cet évènement s'est propagé dans le composant parent.

1ère méthode: déclencher l'évènement avec emit()

L'implémentation du test pourrait être:

describe('ParentComponent', () => {
  let component: ExampleComponent;
  let fixture: ComponentFixture<ExampleComponent>;
  let itemRepositoryService: any;
  let itemService: any;

   beforeEach(() => {
    // Implémentation des mocks pour les services
    itemService = jasmine.createSpyObj(['addItem', 'getItems']);
    itemService.getItems.and.returnValue([
      { id: 0, name: 'item 0'},
      { id: 1, name: 'item 1'},
      { id: 2, name: 'item 2'},
      { id: 3, name: 'item 3'},
    ]);

    itemRepositoryService = jasmine.createSpyObj(['findItemFromId']);

    // Configuration du TestBed avec les mocks des services
    TestBed.configureTestingModule({
      declarations: [ ExampleComponent, ItemComponent ],
      providers: [
        { provide: ItemRepositoryService, useValue: itemRepositoryService },
        { provide: ItemService, useValue: itemService }
      ],
    })

    fixture = TestBed.createComponent(ExampleComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });

  fit('when deleting from item child component then item shall be deleted', () => {    
    // On requête tous les composants enfant dans le rendu HTML (recherche par directive)
    let itemComponents = fixture.debugElement.queryAll(By.directive(ItemComponent));
    expect(itemComponents.length).toBe(4);

    // On indique à Jasmine qu'on souhaite vérifier le comportement de la méthode deleteItem.
    spyOn(fixture.componentInstance, 'deleteItem');

    // On déclenche l'évènement itemDeleted dans un composant enfant
    (<ItemComponent>itemComponents[1].componentInstance).itemDeleted.emit(1);

    // On vérifie que l'évènement s'est propagé dans le composant parent
    expect(fixture.componentInstance.deleteItem).toHaveBeenCalledOnceWith(1);
  });
});

2e méthode: déclencher l'évènement avec DebugElement.triggerEventHandler()

On peut déclencher l'évènement itemDeleted avec DebugElement.triggerEventHandler().

L'implémentation du test pourrait être:

fit('when triggering itemDeleted from item child component then item shall be deleted', () => {    
  // On requête tous les composants enfant dans le rendu HTML (recherche par directive)
  let itemComponents = fixture.debugElement.queryAll(By.directive(ItemComponent));
  expect(itemComponents.length).toBe(4);

  // On indique à Jasmine qu'on souhaite vérifier le comportement de la méthode deleteItem.
  spyOn(fixture.componentInstance, 'deleteItem');

  // On déclenche l'évènement itemDeleted dans un composant enfant
  itemComponents[1].triggerEventHandler('itemDeleted', 1);

  // On vérifie que l'évènement s'est propagé dans le composant parent
  expect(fixture.componentInstance.deleteItem).toHaveBeenCalledOnceWith(1);
});

Mocker HttpClient

HttpClient est utilisé pour effectuer des requêtes HTTP, par exemple, vers une API. Si du code dans un composant ou un service contient des appels avec HttpClient, il peut être difficile d'exécuter ce code dans le cadre d'un test. A ce titre, il est possible de mocker la classe HttpClient et ainsi faciliter l'exécution des tests.

Pour mocker HttpClient, il suffit de substituer HttpClient avec la classe HttpTestingController. Pour utiliser HttpTestingController, il faut:

  • Ajouter le module HttpClientTestingModule et
  • Utiliser HttpClientController dans le module de test.

Par exemple, si on considère le code suivant:

export interface IRepoData {
  id: string;
  node_id: string;
  name: string;
}

@Injectable({
  providedIn: 'root'
})
export class RepoApiService {
  baseURL: string = 'https://api.github.com/';

  constructor(private httpClient: HttpClient) { }

  getRepos(userName: string): Observable<IRepoData[]> {
      return this.httpClient.get<IRepoData[]>(this.baseURL + 'users/' + userName + '/repos');
  }  
}

Cette fonction permet d'interroger une API à l'adresse: https://api.github.com/users/<user name>/repos, par exemple:
https://api.github.com/users/msoft/repos

Parmi les données retournées, on se contente de ne récupérer que les propriétés:

  • ID
  • node_id
  • name

On utilise l'interface IRepoData pour représenter ces données.

Pour appeler le fonction RepoApiSevice.getRepos(), une implémentation pourrait être:

Template
<p>
  Repo name:
  <input #repoName />
  <button (click)='getRepoNames(repoName.value)'>Get Repo data</button>
</p>

<ul>
  <div *ngFor="let repoName of RepoNames">
    <li>{{repoName}}</li>
  </div>
</ul>
Classe du composant
@Component({
  selector: 'app-example',
  templateUrl: './example.component.html'
})
export class ExampleComponent implements OnDestroy {
  RepoNames: string[];
  private isAlive = true;

  constructor(private repoApiService: RepoApiService) { 
    this.RepoNames = [];
  }

  getRepoNames(userName: string): void {
    this.repoApiService.getRepos(userName)
    .pipe(takeWhile(() => this.isAlive))
    .subscribe(repos => {
      this.RepoNames = repos?.map(r => r.name);
    });
  }

  ngOnDestroy(): void {
    this.isAlive = false;
  }
}

Si on indique le nom du username dans la zone input et si on clique sur le bouton, la liste des repos GitHub s'affiche:

On souhaite tester le fonction RepoApiService.getRepos() qui utilise HttpClient.

Dans un premier temps, on va donc mocker la classe HttpClient en utilisant HttpTestingController dans le TestBed:

import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';

import { IRepoData, RepoApiService } from './repo-api.service';

describe('RepoApiService', () => {
  let service: RepoApiService;
  let httpTestingController: HttpTestingController;

  beforeEach(() => {
    TestBed.configureTestingModule({
      imports: [HttpClientTestingModule],
      providers: [
        RemoteApiService, 
      ]
    });

    service = TestBed.inject(RepoApiService);
    httpTestingController = TestBed.inject(HttpTestingController);
  });
});

On importe le module HttpClientTestingModule qui contient HttpTestingController. On utilise inject() pour instancier RepoApiService. Ainsi l'instance de HttpClient injectée dans RepoApiService est de type HttpTestingController.

On peut ensuite implémenter un test qui va appeler RepoApiService.getRepos() et vérifier que l'appel à l'API a bien été effectué:

fit('should fetch list of repos when calling API', () => {
  expect(service).toBeTruthy();

  let repoUserName = 'miscUserName';
  let expectedFetchedRepoData: IRepoData[] = [
    { id:'1', node_id: '544', name: 'firstRepoForTest' },
    { id:'2', node_id: '545', name: 'secondRepoForTest' }
  ];    
  
  service.getRepos(repoUserName).subscribe(
    actualFetchedRepoData => {
      expect(actualFetchedRepoData).toBeTruthy(); 
      expect(actualFetchedRepoData).toEqual(expectedFetchedRepoData);
    }
  );
  const request = httpTestingController.expectOne(`https://api.github.com/users/${repoUserName}/repos`);
  expect(request.request.method).toBe("GET");

  request.flush(expectedFetchedRepoData);
});

Dans ce test, on s'abonne à la fonction RepoApiService.getRepos() avec service.getRepos(repoUserName).subscribe(...).
On vérifie que HttpClient doit être appelé en effectuant une requête avec le verbe HTTP GET à l'adresse https://api.github.com/users/${repoUserName}/repos.
httpTestingController.expectOne() permet de récupérer un mock qui permettra par la suite de simuler la réponse de HttpClient avec request.flush(). La vérification des données obtenues se fait dans la lambda de l'appel service.getRepos().subscribe(...).
Il faut respecter la séquence des appels pour que le test fonctionne.

Quelques méthodes dans HttpTestingController permettent d'effectuer des vérifications:

  • HttpTestingController.match(): permet de retourner un mock TestRequest pour toutes les requêtes effectuées.
  • HttpTestingController.expectNone(): permet de vérifier qu'une requête vers une URL n'a pas été effectuée.
  • HttpTestingController.verify(): vérifie si des requêtes sont en attente. Une erreur est lancée si des requêtes sont en attente.

Dans cet exemple request est de type TestRequest:

  • TestRequest.flush() permet de simuler la réponse à une requête HTTP en indiquant le corps du message.
  • TestRequest.error() permet de simuler une erreur réseau lors de l'appel à HttpClient.
  • TestRequest.event() permet de simuler un évènement sur le flux de la réponse à la requête.
  • TestRequest.request.method permet de récupérer le verbe HTTP utilisé lors de la requête.
  • TestRequest.request.params permet de récupérer les paramètres utilisés dans la requête.

Il n'est pas obligatoire d'utiliser le TestBed pour injecter la classe HttpTestingController. On peut utiliser la méthode inject() directement dans un test, par exemple:

import { inject } from '@angular/core/testing';
// ...

fit('should fetch list of repos when calling API', () => {
  inject([RepoApiService, HttpTestingController], 
    (service: RepoApiService, httpTestingController: HttpTestingController) => 
  {
    // Implémentation du test
    // ...
  })	
});

Tester des exécutions asynchrones

Tester du code exécuté de façon asynchrone présente certaines difficultés car l'exécution n'est pas immédiate, il faut attendre un certain laps de temps pas forcément connu à l'avance pour que cette exécution soit terminée et qu'on puisse effectuer les vérifications du test.

Dans un premier temps, on va simuler dans le composant un traitement asynchrone de façon à indiquer plusieurs possibilités pour implémenter un test pour ce type de code.

Si on reprend l'exemple du paragraphe précédent, le code du composant est:

Template
<p>
  Repo name:
  <input #repoName />
  <button (click)='getRepoNames(repoName.value)'>Get Repo data</button>
</p>

<ul>
  <div *ngFor="let repoName of RepoNames">
    <li>{{repoName}}</li>
  </div>
</ul>
Classe du composant
@Component({
  selector: 'app-example',
  templateUrl: './example.component.html'
})
export class ExampleComponent implements OnDestroy {
  RepoNames: string[];
  private isAlive = true;
  RepoFetched!: boolean;


  constructor(private repoApiService: RepoApiService) { 
    this.RepoNames = [];
  }

  getRepoNames(userName: string): void {
    this.RepoFetched = false;
    this.remoteApiService.getRepos(userName)
      .subscribe(repos => {
        this.RepoNames = repos?.map(r => r.name);
        this.RepoFetched = true;
      };
  }


  ngOnDestroy(): void {
    this.isAlive = false;
  }
}

Ce code permet de récupérer la liste de nom des repos Github grâce à la fonction getRepoNames(). On va modifier cette fonction pour que son exécution soit retardée de façon à simuler un traitement asynchrone. Au préalable, on ajoute
la méthode Javascript suivante:

function executeWithTimeout(func, waitingTime) {
  var context  = this, args = arguments;
  var callback = function() {
      func.apply(context, args);
  };
  setTimeout(callback, waitingTime);
};

Cette méthode retarde l'exécution du code dans l'argument func en utilisant la méthode setTimeout(). Le temps d'attente est précisé avec l'argument waitingTime.

Pour utiliser cette méthode:

  1. On l'ajoute dans un fichier Javascript src/script.js.
  2. On indique la présence de ce fichier dans la configuration Angular dans angular.json:
    {
      ...
      "projects": {
        "angular_application_tests": {
            ...
            "architect": {
            "build": {
              "builder": "@angular-devkit/build-angular:browser",
              "options": {
                ...
                "styles": [
                  "src/styles.css"
                ],
                "scripts": ["src/script.js"]
              },
              "configurations": {
                ...
              },
              "defaultConfiguration": "production"
            },
            ...
          }
        }
      }
    }
    
  3. On ajoute une déclaration pour la méthode Javascript dans le code Typescript:
    declare function executeWithTimeout(func: any, waitingTime: number): void;
    

Pour simuler le retardement de la fonction à exécuter, on modifie la méthode getRepoNames():

getRepoNames(userName: string): void {
  executeWithTimeout(() => {
    this.RepoFetched = false;
    this.repoApiService.getRepos(userName)
    .subscribe(repos => {
      this.RepoNames = repos?.map(r => r.name);
      this.RepoFetched = true;
    });
  }, 250); // 250 ms de retard
}

Si on implémente un test sans prendre en compte le retard lors de l'exécution, ce test échoue.

Par exemple:

describe('ExampleComponent', () => {
  let component: ExampleComponent;
  let fixture: ComponentFixture<ExampleComponent>;  
  let repoApiService: any;
  
  beforeEach(() => {
    repoApiService = jasmine.createSpyObj(['getRepos']);

    TestBed.configureTestingModule({
      declarations: [ ExampleComponent, RouterLinkDirectiveStub ],

      providers: [
        { provide: RepoApiService, useValue: repoApiService },
      ],
    })

    fixture = TestBed.createComponent(ExampleComponent);
    component = fixture.componentInstance;
    fixture.detectChanges();
  });
  
  fit('should fetch list of repos when calling API (', () => {    
    let expectedUserName = 'UserName';

    repoApiService.getRepos.withArgs(expectedUserName).and.returnValue(of([
      { id: 1, node_id: 54, name: 'repo' }
    ]));

    component.getRepoNames(expectedUserName);

    expect(repoApiService.getRepos).toHaveBeenCalled();
    expect(component.RepoFetched).toBe(true);
  });
});

Quelques solutions sont possibles pour implémenter un test dans le cas de l'exécution asynchrone.

setTimeout()

Une première possibilité est d'introduire dans le test, un retard à l'exécution équivalent au retard de l'exécution asynchrone. On modifie le test précédent en exécutant les vérifications avec setTimeout():

fit('should fetch list of repos when calling API (setTimeout)', () => {    
  let expectedUserName = 'UserName';

  repoApiService.getRepos.withArgs(expectedUserName).and.returnValue(of([
    { id: 1, node_id: 54, name: 'repo' }
  ]));

  component.getRepoNames(expectedUserName);
  
  setTimeout(() => {
    expect(repoApiService.getRepos).toHaveBeenCalled();
    expect(component.RepoFetched).toBe(true);
  }, 300);
});

Le test réussit toutefois on n'attend pas la fin de l'exécution de setTimeout(). Pour que Karma attende la fin de l'exécution du test, on modifie le test de cette façon:

fit('should fetch list of repos when calling API (setTimeout)', (done) => {    
  let expectedUserName = 'UserName';

  repoApiService.getRepos.withArgs(expectedUserName).and.returnValue(of([
    { id: 1, node_id: 54, name: 'repo' }
  ]));

  component.getRepoNames(expectedUserName);
  
  setTimeout(() => {
    expect(repoApiService.getRepos).toHaveBeenCalled();
    expect(component.RepoFetched).toBe(true);
    done();
  }, 300);
});

Le test réussit cependant les inconvénients de cette solution sont:

  • La durée d'exécution du test est rallongée à cause de setTimeout().
  • Si on connaît pas le temps de réponse, il sera difficile de configurer le temps d'attente dans l'appel à setTimeout().

Utiliser fakeAsync

Angular utilise la bibliothèque Zone.js pour intercepter les évènements qui se déclenchent dans le browser de façon à permettre en particulier, la détection de changement (cf. Fonctionnement de la détection de changement). Zone.js permet de mettre en place un contexte d'exécution sous forme d'une zone. Pour les besoins de tests asynchrones, l'objet fakeAsync permet de mettre en place une zone dans laquelle les évènements seront interceptés pour qu'ils ne soient pas exécutés normalement:

Dans le cas de l'exemple, si on utilise fakeAsync, le test devient:

fit('should fetch list of repos when calling API (fakeAsync)', <any>fakeAsync(() => {    
  let expectedUserName = 'UserName';

  repoApiService.getRepos.withArgs(expectedUserName).and.returnValue(of([
    { id: 1, node_id: 54, name: 'repo' }
  ]));

  component.getRepoNames(expectedUserName);

  tick(300);
  
  expect(repoApiService.getRepos).toHaveBeenCalled();
  expect(component.RepoFetched).toBe(true);
}));

tick() permet de simuler le passage du temps. Si on ne connaît pas le temps d'exécution, on peut utiliser flush(). flush() exécute toutes les macrotasks en attente d'exécution. Si des macrotasks sont en cours d'exécution, flush() avance l'horloge d'exécution de la zone pour vérifier si les macrotasks ont été réellement exécutées.

Dans le cas de l'exemple, flush() peut être utilisé à la place de tick():

fit('should fetch list of repos when calling API (fakeAsync)', <any>fakeAsync(() => {    
  let expectedUserName = 'UserName';

  repoApiService.getRepos.withArgs(expectedUserName).and.returnValue(of([
    { id: 1, node_id: 54, name: 'repo' }
  ]));

  component.getRepoNames(expectedUserName);

  flush();
  
  expect(repoApiService.getRepos).toHaveBeenCalled();
  expect(component.RepoFetched).toBe(true);
}));

Promise

Dans le cas d'une promise, on peut utiliser:

  • flush() comme pour l'exemple précédent car les promises sont des microtasks.
  • ComponentFixture<T>.whenStable(): permet d'obtenir une promise qui va attendre les promises en cours d'exécution. Ainsi si on utilise ComponentFixture<T>.whenStable().then(...), on pourra exécuter le code effectuant les vérifications quand toutes les promises auront achevé leur exécution. Si on utilise ComponentFixture<T>.whenStable().then(...) dans un test, il faut utiliser waitForAsync() pour que Karma attende la fin de l'exécution du code dans la partie then(...).

Dans le cas de l'exemple, on va modifier le code du composant pour qu'une promise soit en attente d'exécution. Le code de getRepoNames() devient:

Template
<p>
  Repo name:
  <input #repoName />
  <button (click)='getRepoNames(repoName.value)'>Get Repo data</button>
</p>

<ul>
  <div *ngFor="let repoName of RepoNames">
    <li>{{repoName}}</li>
  </div>
</ul>
Classe du composant
@Component({
  selector: 'app-example',
  templateUrl: './example.component.html'
})
export class ExampleComponent implements OnDestroy {
  RepoNames: string[];
  private isAlive = true;
  RepoFetched!: boolean;

  constructor(private repoApiService: RepoApiService) { 
    this.RepoNames = [];
  }

  getRepoNames(userName: string): void {
    this.RepoFetched = false;
    var p = firstValueFrom(this.repoApiService.getRepos(userName)
      .pipe(map(repos => {
      this.RepoNames = repos?.map(r => r.name);
      this.RepoFetched = true;
    })));
  }

  ngOnDestroy(): void {
    this.isAlive = false;
  }
}

En utilisant ComponentFixture<T>.whenStable() dans le test pour attendre la fin de l'exécution de la promise, le code devient:

fit('should fetch list of repos when calling API (whenStable)', waitForAsync(() => {    
  let expectedUserName = 'UserName';

  repoApiService.getRepos.withArgs(expectedUserName).and.returnValue(of([
    { id: 1, node_id: 54, name: 'repo' }
  ]));

  component.getRepoNamesAsyncPromise(expectedUserName);

  fixture.whenStable().then(() => {
    expect(repoApiService.getRepos).toHaveBeenCalled();
    expect(component.RepoFetched).toBe(true);  
  });
}));
Références