Introduction

Cet article a pour objectif premier de comprendre comment les conversions fonctionnent, et non d’expliciter comment PyO3 marche en détail.

Utiliser un autre langage dans un code Python est commun. Le plus naturel est le C, car c’est le langage dans lequel a été développé Python, comme les bibliothèques numpy ou panda. Malgré tout, le Rust, par la sécurité que le langage procure, devient petit à petit une alternative au C pour écrire des bibliothèques Python comme polar, qui est une alternative à panda.

PyO3

PyO3 est une bibliothèque Rust permettant d’appeler du code Rust directement dans Python. Cela n’est pas évidant car le Rust est un langage compilé typé, tandis que le Python est générique et interprété. L’objectif de cet article est de comprendre comment les types sont gérés lors de cette conversion. Dans cet article, on se basera sur la release 0.25.1 de PyO3.

Rust : analyse de PyO3

Nous allons nous confronter à un exemple, mais cela n’est pas forcément une généralité. En effet, les types peuvent se gérer différemment et il faut le garder en tête. Commençons par essayer de trouver la conversion générique, puis focalisons nous sur l’exemple du type float.

Comment PyO3 devine les types Python ?

Comme Python est un langage générique, on ne sait pas quels arguments vont être donnés. Cependant, Rust est typé, il doit donc vérifier que les arguments donnés soient bons. Pour commencer, il faut savoir que tous les objets Python sont représentés par un PyObject. Pour cela, il faut regarder du côté des fichiers suivants :

  • pyO3-ffi/src/object.rs : contient la fonction Py_TYPE et la méthode Py_IS_TYPE.
1
    fn _Py_TYPE(arg1: *const PyObject) -> *mut PyTypeObject;
  • pyO3-ffi/src/cPython/object.rs : contient PyTypeObject. On remarque le PyObject vient de l’interface C. Ces 3 éléments permettent de deviner le type d’un object Python.

Voici un graphique, non exhaustif, qui permet de voir ce qui se passe.

type_cast

En résumé, Py_TYPE utilise l’interface C pour son argument qui est le PyObject, Py_TYPE produit un PyTypeObject, et Py_IS_TYPE utilise un PyTypeObject et un PyObject. Grâce à ces éléments, PyO3 peut faire la vérification des types. Il y a bien sûr plus de subtilités au niveau des implémentations techniques, mais ce schéma donne déjà une bonne idée de la procédure.

Une autre méthode est de prendre notre objet PyObject et de le convertir en un autre object Python comme PyFloat.

La conversion Python-Rust

Pour commencer, on va se focaliser sur la conversion de Python vers Rust. Regardons le fichier src/conversion.rs :

  • src/conversion.rs : permet de prendre un PyAny et retourne le bon type grâce à sa méthode extract. Le type PyAny se trouve dans pyO3/src/types/any.rs. Finalement, il faut chercher dans pyO3-macros-backend/src/frompyobject.rs et src/conversion.rs.
  • src/type_object.rs : contient des méthodes comme is_type_of, is_exact_type_of ou type_object_raw.

Dans src/conversion.rs, on trouve IntoPyObject et FromPyObject. Cependant, la méthode de conversion peut différer en fonction du type. Ainsi, il est préférable de prendre un exemple.

La conversion Python-Rust et les flottants

Dans src/types/float.rs, on a

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
impl<'py> FromPyObject<'py> for f64 {
    ...
    fn extract_bound(obj: &Bound<'py, PyAny>) -> PyResult<Self> {
        ...
        
        let v = unsafe { ffi::PyFloat_AsDouble(obj.as_ptr()) };

        if v == -1.0 {
            if let Some(err) = PyErr::take(obj.py()) {
                return Err(err);
            }
        }

        Ok(v)
    }

    #[cfg(feature = "experimental-inspect")]
    fn type_input() -> TypeInfo {
        Self::type_output()
    }
}

Dans le code d’origine, il y avait l’application de la méthode downcast_exact qui est là pour un problème de performance que nous n’allons pas développer. Pour en savoir plus, vous pouvez regarder la pull request qui l’implémente.

Il faut savoir que PyFloat_AsDouble est dans l’interface C. Cela permet d’expliquer pourquoi une méthode globale est compliquée à trouver pour la conversion. En effet, la conversion se fait au cas par cas car il utilise des fonctions de l’interface C qui ne sont pas les mêmes pour tous les types.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
impl PyFloat {
    ...
    pub fn new(py: Python<'_>, val: c_double) -> Bound<'_, PyFloat> {
        unsafe {
            ffi::PyFloat_FromDouble(val)
                .assume_owned(py)
                .downcast_into_unchecked()
        }
    }
}

...

impl<'py> IntoPyObject<'py> for f64 {

    ...
    
    fn into_pyobject(self, py: Python<'py>) 
        -> Result<Self::Output, Self::Error> {
        Ok(PyFloat::new(py, self))
    }
    
    ...
}

PyFloat_FromDouble fait partie de l’API C de Python. Donc, de même, la conversion se fait grâce à l’interface C.

Résumé

conversion

Les fichers et PyO3

Les fichiers ne sont pas utilisables avec PyO3, expliquons pourquoi. Pour commencer, l’interface entre ce qui permet de convertir les fichiers Python en file descriptor est bien implémentée dans pyO3-ffi/src/fileobject.rs.

Ainsi, pyO3 a déjà la fonction de conversion de Python vers C implémentée, et on peut donc bien traiter les fichiers. Cependant, le type fichier n’est pas créé et implémenté dans PyO3 et nous allons en chercher la raison.

Dans la documentation, la seule fonction qui permet de convertir un fichier Python en fichier C est la fonction PyObject_AsFileDescriptor qui retourne un file descriptor. Une des raisons qui pourrait expliquer cette absence est le fait que Windows n’utilise pas de file descriptors mais une structure HANDLE pour ses fichiers. Cependant, il est toujours possible de convertir un file descriptor en HANDLE. Il faudrait plutôt regarder dans la logique d’implémentation :

  • Qui doit fermer le fichier ?
  • Si Python décide de fermer le fichier, comment doit-on gérer l’erreur ?
  • Comment être sûr du type ? En effet, un file descriptor peut définir une pipe, un terminal ou un socket.
  • Unix et Windows ont des comportements bien différents. Par exemple, il n’y a aucune API officielle pour connaître le mode d’ouverture d’un fichier dans Windows.

Les développeurs avaient sûrement des fonctionnalités plus importantes à implémenter ou n’ont pas eu le temps de répondre à ces questions pour implémenter ces fichiers. D’autant plus que l’objet PyFile n’a pas de structure. Il n’existe que des PyObject dans l’API C de Python.

Dans ce cas, il ne nous reste plus qu’à l’implémenter (seulement pour les systèmes Unix).

Ajouter le type file pour PyO3

Il faudra d’abord rajouter nix = "0.26" dans les dépendances de PyO3.

Il faut créer le fichier src/types/file.rs où on mettra notre PyFile.

 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
28
29
30
31
32
33
34
35
36
37
38
use crate::{ffi, instance::Bound, PyAny, PyErr, PyResult, Python, FromPyObject, IntoPyObject};
use std::fs::File;
use std::os::fd::{AsRawFd, FromRawFd};

#[repr(transparent)]
pub struct PyFile(PyAny);

impl PyFile {
    // La création de notre PyFile
    pub fn new(py: Python<'_>, file: File) -> PyResult<Bound<'_, PyAny>> {
        let fd = file.as_raw_fd();

        unsafe {
        
            // On apelle PyFile_FromFd qui est une fonction C de la documentation Python
            let py_obj = ffi::PyFile_FromFd(
                dup(fd).expect("Mauvais fd"),
                std::ptr::null(), // nom
                std::ptr::null(), // mode d'ouverture
                -1,               // taille du buffer
                std::ptr::null(), // encodage
                std::ptr::null(), // erreurs
                std::ptr::null(),
                1,
            );

            /*
             * On vérifie si l'objet a bien été créé
             */

            if py_obj.is_null() {
                Err(PyErr::fetch(py))
            } else {
                Ok(Bound::from_owned_ptr(py, py_obj as *mut _))
            }
        }
    }
}

Python et Rust ferment tous les deux le fichier qu’ils ont par défaut. Ainsi, dup permet de créer un nouveau file descriptor indépendant de l’original afin que les deux langages puissent fermer leur fichier sans problème.

Ensuite, il faut ajouter la méthode pour bind Python vers Rust.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
use nix::unistd::dup;

impl<'py> FromPyObject<'py> for File {
    /*
     * C'est à cet endroit que pyO3 convertit l'objet Python en File
     */
    fn extract_bound(obj: &Bound<'py, PyAny>) -> PyResult<Self> {
        unsafe {
            let fd = ffi::PyObject_AsFileDescriptor(obj.as_ptr());
            if fd < 0 {
                Err(PyErr::fetch(obj.py()))
            } else {
                // Un dup pour éviter les erreurs
                let dup_fd = nix::unistd::dup(fd)
                    .map_err(
                    |e| 
                    PyErr::new::<crate::exceptions::PyOSError, _>(
                    e.to_string()))?;
                Ok(File::from_raw_fd(dup_fd))
            }
        }
    }
}

Ensuite, il faut ajouter la méthode pour bind le Rust vers le Python.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
impl<'py> IntoPyObject<'py> for File {
    type Target = PyAny;
    type Output = Bound<'py, PyAny>;
    type Error = PyErr;

    fn into_pyobject(self, py: Python<'py>) 
        -> Result<Self::Output, Self::Error> {
        PyFile::new(py, self) 
    }
}

Il ne reste plus qu’à ajouter pub(crate) mod file; dans src/types/mod.rs afin de rendre accessible notre type.

Résumé

files

Implémenter l’environnement

Créer un nouveau projet avec la commande cargo init.

Dans Cargo.toml, il faudra mettre :

1
2
3
4
5
6
[lib]
name = "test_custom_file"
crate-type = ["cdylib"]

[dependencies]
pyO3 = { path = "path_to_pyO3/pyO3", version = "0.25.0" }

Il faut maintenant créer un simple programme test dans src/lib.rs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
use pyO3::prelude::*;
use std::fs::File;
use std::io::Read;

#[pyfunction]
fn my_custom_file(mut file: File) -> PyResult<String> {
    let mut contents = String::new();

    file.read_to_string(&mut contents)?;

    Ok(contents)
}

#[pymodule]
fn test_custom_file(m: &Bound<'_, PyModule>) -> PyResult<()> {
    m.add_function(wrap_pyfunction!(my_custom_file, m)?)?;

    Ok(())
}

Il faut maintenant compiler le programme :

1
cargo build && cp target/debug/libtest_custom_file.so test_custom_file.so

Ensuite, nous devons écrire du texte dans le fichier something.txt avec la commande echo foo > something.txt et on pourra alors lancer le programme Python suivant.

1
2
3
4
5
import test_custom_file

with open("somefile.txt", "r") as f:
    result = test_custom_file.my_custom_file(f)
    print(result)

Une comparaison avec le C

Comme Rust utilise les fonctions C pour communiquer avec Python, pourquoi utiliser pyO3 plutôt que le C directement comme prévu par la documentation Python?

Rust offre des nombreux avantages conséquents par rapport au C tout en gardant des performances très proches :

  • la sécurité mémoire
  • une concurrence sûre
  • un écosystème moderne
  • la simplicité d’écrire des fonctions Python en Rust grâce aux macros

Conclusion

PyO3 est une bibliothèque intéressante pour développer des bibliothèques Python grâce aux avantages du langage Rust. Cependant, elle est encore en développement et manque de fonctionnalités importantes, comme on l’a vu avec le type fichier. Cependant, ça ne saurait tarder.

Annexes