Dans cet article, nous allons voir comment utiliser une DLL développée avec le langage C / C++ dans un script Python. Wrapper une DLL n’est pas un travail aussi fastidieux que cela ne laisse paraître. A la fin de cet article, vous devriez pouvoir facilement le faire vous-même.
Charger des DLL avec le module ctypes
Pour utiliser une DLL avec votre programme Python, il faut commencer par importer le module ctypes qui est fourni par défaut avec le langage Python. Le module ctypes permet de travailler indifféremment avec les conventions :
- C cdecl en utilisant
cdll) ; - Windows stdcall en utilisant
WinDLL) ouOleDLL(les fonctions doivent toutes retourner en plus un code d’erreur HRESULT).
C’est avec le module ctypes que vous allez pouvoir utiliser les fonctions exportées de la DLL. Elles seront chargées à l’aide de la méthode LoadLibrary(). Voici un exemple simple pour illustrer le fonctionnement du module :
from ctypes import cdll
libc = cdll.LoadLibrary("test.dll")
libc.Somme(4, 5)
Les appels des fonctions exportées de la bibliothèque partagée se font comme si ces fonctions étaient des méthodes de l’objet cdll.
Évidemment, vous pouvez aussi charger des bibliothèques Linux en .so au lieu de .dll :
from ctypes import cdll
libc = cdll.LoadLibrary("test.so.6")
libc.Somme(4, 5)
Les DLL exportent parfois des fonctions dont les noms ne sont pas des identifiants Python valides, comme "??2@MaFonction@Z". Dans ce cas, il faut utiliser getattr() pour accéder à la fonction :
>>> getattr(cdll.msvcrt, "??2@MaFonction@Z")
<_FuncPtr object at 0x...>
Si votre DLL a été développée et compilée avec Visual Studio, vous pouvez facilement obtenir le nom des fonctions de la bibliothèque à l’aide de la commande suivante :
C:\Program Files (x86)\Microsoft Visual Studio xxxx.xxxx\VC\bin\link.exe /dump /exports "C:\xxxxxxx\Test.dll"
Attention, pensez à adapter la version de Visual Studio et le chemin vers la DLL en fonction de votre contexte.
Les types de données C / C++ et Python
Pour déterminer la convention d’appel d’une fonction, il faut consulter l’en-tête C ou la documentation de la fonction à appeler.
Une fois la signature de la fonction identifiée, il faut faire le lien entre les types de données C et ceux de Python.
Pour cela, ctypes définit plusieurs types de données de base compatibles avec le C et dont voici le tableau récapitulatif :
| ctypes | C / C++ | Python |
|---|---|---|
| c_bool | _Bool | booléen |
| c_char | char | objet octets (1 caractère) |
| c_wchar | wchar_t | string (1 caractère) |
| c_byte | char | int |
| c_ubyte | unsigned char | int |
| c_short | short | int |
| c_ushort | unsigned short | int |
| c_int | int | int |
| c_uint | unsigned int | int |
| c_long | long | int |
| c_ulong | unsigned long | int |
| c_longlong | __int64 ou alors long long | int |
| c_ulonglong | unsigned __int64 ou alors unsigned long long | int |
| c_size_t | size_t | int |
| c_ssize_t | ssize_t ou alors Py_ssize_t | int |
| c_float | float | float |
| c_double | double | float |
| c_longdouble | long double | float |
| c_char_p | char * (se termine par NULL) | objet bytes ou None |
| c_wchar_p | wchar_t * (se termine par NULL) | string ou None |
| c_void_p | void * | int ou None |
Appel de fonctions
Grâce au tableau précédent qui synthétise toutes les relations entre les types de données Python et ceux du langage C, nous pouvons maintenant étudier un exemple d’appel.
Prenons une fonction tirée de l’en-tête C d’une DLL avec pour signature :
MA_FONCTION_API int MaFonction(
char* vCharacters,
int nCharacters,
char*& vResults);
Pour comprendre comment procéder à l’appel de la fonction, nous devons préalablement passer en revue chaque paramètre.
La liste d'octets vCharacters
Nous constatons que vCharacters est de type char*. En se basant sur le tableau pour trouver l’équivalent Python, cela signifie que je dois donc fournir au paramètre une liste d’octets. Par conséquent, si nous avons en entrée une chaine de caractère Python, il faut simplement l’encoder à l’aide de .encode() pour que le paramètre soit valide. Ainsi, la chaine de caractères sera transformée en un tableau de bytes qui pourra être interprété comme un char*. Attention, soyez toujours vigilant à l’encodage utilisé dans le script Python par rapport à celui utilisé dans la DLL.
L'entier nCharacters
Toujours d’après le tableau, pour nCharacters, je dois simplement lui fournir un entier. J’ai choisi, arbitrairement, de lui donner un entier qui correspond au nombre de caractères de la chaine utilisée pour générer vCharacters.
Le buffer vResults
Enfin, vResults est de type char* &. Cela signifie que ce paramètre est en fait un résultat généré par la fonction vResults dont le contenu sera une liste d’octets. Il faut donc fournir par référence (&), au paramètre vResults un pointeur (*) vers un buffer (char).
Un buffer est une zone mémoire que l’on alloue spécifiquement pour garder en mémoire les caractères fournis par la fonction. Pour créer un buffer, il me suffit d’utiliser l’objet create_string_buffer du module ctypes. Généralement, la taille maximale du buffer est limitée, pensez à préciser cette taille !
Enfin, le buffer a besoin d’un pointeur. Il suffit de l’ajouter avec l’objet pointer() du module ctypes. Pour finir, on spécifiera que ce paramètre est fourni par référence à l’aide de l’objet byref() du module ctypes.
Appel de la fonction MaFonction
Pour résumer, le code complet d’appel de la fonction MaFonction ressemblera à cela :
from ctypes import cdll, pointer, create_string_buffer, byref
# Maximum of 200 000 characters
max_buffer_size = 200000
characters = "Mes petites données.".encode('latin1')
n_characters = len(characters)
p_buffer_results = pointer(create_string_buffer(max_buffer_size))
libc = cdll.LoadLibrary("test.dll")
code_erreur = libc.MaFonction(characters, n_characters, byref(p_buffer_results))
Après l’exécution de la fonction, il faut vérifier que celle-ci ne retourne aucun code erreur. Ensuite, on pourra lire le contenu du buffer qui contient le résultat, le décoder et supprimer le buffer :
if code_erreur < 0 :
del p_buffer_results
raise Exception("MaFonction error code: " + str(code_erreur))
result = p_buffer_results.contents.value.decode('latin1')
del p_buffer_results
# Free p_buffer content
self.buffers[thread_id].contents.raw=self.empty_buffer
Attention, si on souhaite réutiliser le buffer plutôt que de le supprimer afin de toujours conserver et utiliser le même espace mémoire, il faudra écrire la valeur Null dans chaque octet de la mémoire allouée au buffer :
# Free p_buffer content
p_buffer_results.contents.raw=b"\x00"*max_buffer_size
Intégrer une DLL dans une classe Python
Maintenant que nous avons vu les bases pour utiliser les fonctions d’une bibliothèque, finissons proprement le travail.
Pour cela, nous allons intégrer les fonctions de la DLL dans une classe Python.
A minima, cette classe devra :
- vérifier l’existence de la DLL ;
- contrôler la signature des fonctions ;
- libérer la mémoire des buffers ;
- gérer les codes erreurs retournés par la DLL.
Comme les deux derniers points ont déjà été abordés précédemment dans l’article, nous allons nous attarder rapidement sur les deux premiers points.
Vérifier l'existence de la DLL
Cela peut paraître évident pour tous, mais je tiens à vous rappeler que vérifier dans l’initialisation de la classe que la DLL existe pourrait bien vous épargner des heures de debug. Laissons la place au code :
def __init__(self, dll = "C:\\Test.dll", max_buffer_size = 200000, encodage = "latin1"):
if not os.path.isfile(dll):
raise Exception("dll not found: " + str(dll))
libc = cdll.LoadLibrary(dll)
Contrôler la signature des fonctions
Comparer au point précédent, celui-ci parait moins évident. Toutefois, l’utilité de réécrire la signature des fonctions de la DLL dans l’initialisation de la classe a beaucoup d’avantages. En effet, réécrire la signature des fonctions permet :
- de ne plus avoir à consulter l’en-tête C ;
- de lever proprement des erreurs de types si les données fournies aux paramètres ne sont pas compatibles.
De plus, la signature d’une fonction n’est pas un élément qui change souvent. Si une signature évolue, dans tous les cas, vous aurez à faire évoluer votre wrapper. Donc cela n’apporte pas de travail supplémentaire en cas d’évolution des fonctions de la bibliothèque.
En reprenant l’exemple de MaFonction cela donne :
MaFonction = getattr(libc,"MaFonction")
MaFonction.argtypes = [c_char_p, c_int, c_void_p]
MaFonction.restype = c_int
Code complet de l'intégration de la DLL dans une classe Python
Comme tous les points importants pour wrapper une DLL dans une classe Python ont été abordés, voici l’intégralité du code :
from ctypes import cdll, pointer, c_int, c_char_p, c_void_p, create_string_buffer, byref
class MaLib:
def __init__(self, dll = "C:\\Test.dll", max_buffer_size = 200000, encodage = "latin1"):
if not os.path.isfile(dll):
raise Exception("dll not found: " + str(dll))
libc = cdll.LoadLibrary(dll)
self.encodage = encodage
# DLL functions signature
self.MaFonction = getattr(libc,"MaFonction")
self.MaFonction.argtypes = [c_char_p, c_int, c_void_p]
self.MaFonction.restype = c_int
# Load environement
self.p_buffer_results = pointer(create_string_buffer(max_buffer_size))
self.empty_buffer = b"\x00"*max_buffer_size
def ma_fonction(self, characters):
n_characters = len(characters)
error_code = self.MaFonction(characters.encode(self.encodage),
n_characters,
byref(self.p_buffer_results))
if error_code < 0 :
self.p_buffer_results.contents.raw=self.empty_buffer
raise Exception("MaFonction error code: " + str(error_code))
result = p_buffer_results.contents.value.decode(self.encodage)
# Free p_buffer content
self.p_buffer_results.contents.raw=self.empty_buffer
return result
Le mot de la fin
Utiliser efficacement et proprement des DLL en Python n’est jamais simple la première fois. Toutefois, la documentation est vraiment très détaillée et vous y trouverez la majorité des réponses aux questions que vous pourriez vous poser : https://docs.python.org/3/library/ctypes.html.
Enfin, si vous avez besoin d’aide ou si vous avez des questions, n’hésitez pas à me les poser en commentaire.
Bonjour,
J’ai bien vu votre tutoriel pas à pas est je trouve cela super et efficace.
Par contre j’ai une petite question. Comment wrapper un class c++ en python? J’ai vu que dans la plus part des forums utilisent Boost.
Pourriez-vous m’aider s’il vous plaît? J’ai créé une dll avec Boost mais c’est pas reconnu par python
Merci à vous
EL HAMRI
Bonjour,
Voici un exemple concret ( que j’ai repris d’un stackoverflow) pour une classe:
1. le code C++ avec une classe
2. La compilation
3. Le code python
4. Exécuter le code (jusqu’à python 3.8, à priori en 3.10 il y a des soucis avec g++)
On notera que l’on peut tout faire de base avec ctypes dont le lien principal à conserver vers sa documentation (en anglais) est ici :
documentation ctypes
Mais à priori, utiliser Boost est aussi une très bonne approche c’est à toi de voir. Çà dépend de ton contexte en fait. Personnellement j’aime beaucoup Ctypes car c’est natif et multi-plateformes.
J’espère que ma réponse t’aidera !