Як я опублікував свій перший пакет Rust-Python биндингу

Я чув багато хороших відгуків про Rust, і щоразу, коли це траплялося, мене трошки більше тягнуло зануритись і дізнатись про нього більше. Тому я пройшов курс на Udemy, почав читати офіційну книгу по Rust, попрацював з rustlings, а потім почав шукати відкритий проєкт, до якого можна було б долучитись. І ось, випадково, але й не зовсім випадково, оскільки я шукав еквівалент SQLite для бази даних на зразок MongoDB, я натрапив на PoloDB, але не зміг використати його в моєму Python-додатку, тому залишив повідомлення, що коли автор буде зацікавлений у написанні биндів, я з радістю допоможу. Кілька місяців потому я вже писав rust-python бинди для цього проєкту — це стало моїм першим Rust-відкритим проєктом.

pic

Що ви дізнаєтесь з цієї статті:

Ця стаття є ретрансляцією мого шляху та труднощів, з якими я стикався як Python-розробник, який намагається вивчити Rust, створюючи python-rust бинди.
Отже, ви будете слідкувати за тим, як проходив цей невеликий шлях, і навчитесь разом зі мною:

  • Які інструменти потрібні для написання біндів
  • Як організувати структуру директорій проєкту між Python та Rust
  • Зрозуміти трохи про GIL (global interpreter lock) та як Rust і Python передають об'єкти один одному
  • І, на останок, як упакувати ваш Rust Python проєкт

Інструменти, які потрібні для написання біндів

Інструменти Python:

  • Менеджер пакетів: Вам потрібен менеджер пакетів для керування залежностями Python, я б порекомендував Poetry або UV, я нещодавно почав використовувати UV і доволі задоволений
  • Maturin: maturin — це інструмент (та пакет для Python), який допомагає будувати та пакувати Python пакети, що містять код на Rust, використовуючи pyo3

Інструменти Rust:

  • Менеджер пакетів: Cargo — це офіційний менеджер пакетів для Rust
  • Pyo3: Pyo3 — це бібліотека з вбудованими типами, які допомагають передавати об'єкти з Python в Rust і навпаки

Структура проєкту:

Нижче представлена структура нашого проєкту

polodb-python/  
┣ polodb/  
┃ ┣ __init__.py  
┃ ┣ core.py  
┃ ┗ version.py  
┣ src/  
┃ ┣ helper_type_translator.rs  
┃ ┣ lib.rs  
┃ ┗ py_database.rs  
┣ tests/  
┃ ┣ conftest.py  
┃ ┗ test_database.py  
┣ .gitignore  
┣ Cargo.lock  
┣ Cargo.toml  
┣ LICENSE.txt  
┣ README.md  
┣ pyproject.toml  
┗ uv.lock

Якщо ви звернете увагу, у вас є src/.rs та cargo.toml, як у звичайному Rust проєкті, і polodb/.py та pyproject.toml, як у звичайному Python проєкті. Те, що "головним чином" буде відрізнятись, це те, що у вашому lib.rs ви визначатимете Rust Python-класи та Python-функції, які хочете експонувати до вашого Python коду, а у вашому pyproject.toml ви визначатимете maturin як бекенд для побудови вашого Rust коду як Python модулю для вашого Python проєкту

# в вашому pyproject.toml   
[build-system]  
requires = ["maturin>=1,<2"]  
build-backend = "maturin"
// у вашому lib.rs  
use pyo3::prelude::*;  

mod helper_type_translator;  
mod py_database;  

use py_database::PyCollection;  
use py_database::PyDatabase;  

#[pymodule]  
fn rust_polodb(m: &Bound<'_, PyModule>) -> PyResult<()> {  
 m.add_class::<PyCollection>()?;  
 m.add_class::<PyDatabase>()?;  

 Ok(())  
}

Використання вашого Rust коду в Python:

Якщо подивитись на Cargo.toml та pyptoject.toml проєкту, ми маємо додаткові записи в cargo.toml та pyproject.toml, одним з прикладів цих записів є залежність pyo3 в Rust, яка є тим, що maturin використовує для компіляції вашого Rust коду у .so, що можна використовувати з Python. Давайте подивимось, що це робить і як.

У нашому проєкті, запустивши maturin develop:

## maturin develop ми отримаємо таку структуру цілі:   

target/  
| |- debug/*  
┃ ┣ examples/*  
┃ ┣ incremental/*  
┃ ┣ maturin/  
┃ ┃ ┗ librust_polodb.dylib  
┃ ┣ .cargo-lock  
┃ ┣ librust_polodb.d  
┃ ┗ librust_polodb.dylib  
┣ wheels/  
┃ ┗ polodb_python-0.1.17-cp311-cp311-macosx_11_0_arm64.whl  
┣ ...

## Це також створить .so, яке дозволить вашому Python проєкту імпортувати його як звичайний Python модуль   
.venv/lib/python3.11/site-packages/rust_polodb/rust_polodb/  
┣ __pycache__/  
┃ ┗ __init__.cpython-311.pyc  
┣ __init__.py  
┗ rust_polodb.cpython-311-darwin.so

З maturin develop вам не потрібно інсталювати згенеровані колеса, тому що maturin створює в вашому середовищі (в моєму випадку .venv) відповідний модуль (в моєму випадку rustpolodb). Якщо б ви замість цього запустили _maturin build, було б згенеровано тільки колесо, і в такому випадку вам довелося б встановити його вручну за допомогою pip install polodbpython-0.1.17-cp311-cp311-macosx110arm64.whl, також зверніть увагу, що назва колеса будується на основі вашого пакета, версії Python та ОС.

Експонування Rust коду до Python:

Ви вже бачили загальний огляд того, як організувати наш проєкт, тепер можна заглибитися і поглянути на cargo.toml та pyproject.toml, щоб побачити, як вони налаштовані.

Тепер ми трохи детальніше розглянемо, як створюється rust-python біндінг.

Я зіткнувся з двома труднощами під час виконання цього проєкту: одна була досить легкою для вирішення, інша вимагала трохи більше досліджень. Перша — це як визначити Python класи, методи та функції, інша — як перетворювати складні типи, як у моєму випадку тип документа bson, який повертається з бібліотеки PoloDB до Python об'єкта, або навпаки, як перетворювати dict в тип документа bson в Rust, тримаючи GIL Python, щоб передавати об'єкти.

  1. Python класи, методи та функції в Rust:
use pyo3::prelude::*;  

/// Проста функція, що додає два числа та повертає результат.  
#[pyfunction]  
fn add_numbers(a: i32, b: i32) -> i32 {  
 a + b  
}  

/// Проста структура, яку ми хочемо експонувати до Python.  
#[pyclass]  
struct Greeting {  
 name: String,  
}  

#[pymethods]  
impl Greeting {  
 /// Конструктор: Greeting::new("Alice")  
 #[new]  
 fn new(name: String) -> Self {  
 Greeting { name }  
 }  

 /// Метод, що повертає привітальне повідомлення.  
 fn hello(&self) -> PyResult<String> {  
 Ok(format!("Hello, {}!", self.name))  
 }  
}  

/// Цей модуль є Python модулем з ім'ям `pyo3_example` і є колекцією класів/функцій.  
#[pymodule]  
fn pyo3_example(py: Python, m: &PyModule) -> PyResult<()> {  
 // Додаємо функцію до модуля  
 m.add_function(wrap_pyfunction!(add_numbers, m)?)?;  

 // Додаємо клас до модуля  
 m.add_class::<Greeting>()?;  

 Ok(())  
}

Цей мінімалістичний приклад доволі простий для розуміння, pyo3 надає кілька макросів для експонування модуля #[pymodule], класу #[pyclass], методу #[pymethods] та Python функції #[pyfunction]. У цьому прикладі вам не потрібно турбуватись про конвертацію типів, тому що типи прості, і pyo3 самостійно обробляє перетворення типів.

2.

Перетворення типів для складних об'єктів:

Rust бібліотека PoloDB надає API, яке імітує MongoDB API, тож якщо ви знайомі з MongoDB, ви знаєте, що працюєте з об'єктами бази даних (об'єкт, що містить одну або декілька колекцій), об'єктами колекцій (об'єктами, схожими на списки, які містять документи), а документи можуть мати строгу схему або бути більш гнучкими, як словники. У моєму Python біндінгу я не працював з документами з жорсткими схемами.

Ось мінімалістичний приклад, де я визначаю класи Databse та Collection, і в методах колекцій використовую функції для перетворення типів:

use crate::helper_type_translator::{  
 bson_to_py_obj, convert_py_obj_to_document  

};  
use polodb_core::bson::Document;  
use polodb_core::options::UpdateOptions;  
use polodb_core::{Collection, Database};  

#[pyclass]  
pub struct PyDatabase {  
 inner: Arc<Mutex<Database>>,  
}  

#[pymethods]  
impl PyDatabase {  
 #[new]  
 fn new(path: &str) -> PyResult<Self> {  
 let db_path = Path::new(path);  
 match Database::open_path(db_path) {  
 Ok(db) => Ok(PyDatabase {  
 inner: Arc::new(Mutex::new(db)),  
 }),  
 Err(e) => Err(PyOSError::new_err(e.to_string())),  
 }  
 }  
 //... інші методи  
}  

#[pyclass]  
pub struct PyCollection {  
 inner: Arc<Mutex<Collection>>, // Використовуємо Arc для безпечного доступу з кількох потоків  
}  

#[pymethods]  
impl PyCollection {  
 pub fn name(&self) -> &str {  
 self.inner.name()  
 }  
 pub fn insert_one(&self, doc: Py<PyAny>) -> PyResult<Py<PyAny>> {  
 // Одержуємо GIL (Global Interpreter Lock) Python  
 Python::with_gil(|py| {  
 let bson_doc: Document = match convert_py_obj_to_document(&doc.into_py_any(py).unwrap()) {  
 Ok(d) => d,  
 Err(e) => return Err(PyRuntimeError::new_err(format!("Insert many error: {}", e))),  
 };  
 // let bson_doc = convert_py_to_bson(doc);  
 match self.inner.insert_one(bson_doc) {  
 Ok(result) => {  
 // Створюємо Python об'єкт з результату Rust та повертаємо його  
 let py_inserted_id = bson_to_py_obj(py, &result.inserted_id);  
 let dict = PyDict::new(py);  
 let dict_ref = dict.borrow();  
 dict_ref.set_item("inserted_id", py_inserted_id)?;  
 Ok(dict.into_py_any(py).unwrap())  

 // Ok(Py::new(py, result)?.to_object(py))  
 }  
 Err(e) => {  
 // Піднімаємо виключення Python у разі помилки  
 Err(PyRuntimeError::new_err(format!("Insert error: {}", e)))  
 }  
 }  
 })  
 }  
//...
## Більше методів тут  
}

У цьому мінімалістичному прикладі я визначаю, що складає мій python клас бази даних та клас колекції, які фактично є обгортками для об'єктів Polodb. Зверніть увагу на методи _convert_py_obj_to_document_ та _bson_to_py_obj_, ці методи я використовую для перетворення об'єктів з Python до Rust Bson документів або з Bson документів назад в об'єкти Python. Також зверніть увагу, що я роблю це в контексті утримання GIL, яке представляється через |py|. Це означає, що поки я утримую GIL, інтерпретатор Python не може виконувати код, поки GIL не буде звільнений. Це запобігає зміні стану об'єкта, коли я передаю його з Python в Rust і навпаки. Якщо ви хочете зрозуміти більше про те, як працює GIL в Python, моя порада — подивитись це [відео](https://www.youtube.com/watch?v=KVKufdTphKs&list=PLP05cUdxR3KsS3yWl5LRiko2lRAp1XPUd&index=1) від Ларрі Гастінгса на PyCon 2015.

Щоб не робити статтю занадто довгою, якщо ви хочете побачити, як реалізовано перетворення, зверніться до файлу репозиторію [helper_type_translator.rs](https://github.com/PoloDB/polodb-python/blob/main/src/helper_type_translator.rs).

Після того, як реалізація в Rust завершена і після запуску _maturin develop_, в Python, я просто маю імпортувати ці класи або функції та використовувати їх. У моєму випадку я зробив невелику обгортку, яка намагається імітувати P[yMongo client](https://pymongo.readthedocs.io/en/stable/tutorial.html), як показано нижче:

from rust_polodb import PyDatabase, PyCollection

class PoloDB:

def init(self, path: str) -> None:
self.path = path
self.
rustdb = PyDatabase(self._path)

def collection(self, name):
if name not in self.listcollectionnames():
self.rustdb.createcollection(name)
return Collection(self.
rust_db.collection(name))
...
....

class Collection:
def init(self, rustcollection) -> None:
self.
rustcollection: PyCollection = rust_collection

def name(self):
return self._rustcollection.name()

def insertone(self, entry: dict):
return self.
rustcollection.insert_one(entry)
...
...
```

Уроки, які я виніс

Написання rust-python біндінгу може здатися трохи складним на перший погляд, але насправді це не так вже й важко. Це додає потужний інструмент до вашого інструментарію, особливо якщо ви стикаєтеся з проблемами продуктивності в Python. Проєкт змусив мене краще зрозуміти Rust, я знайшов його дуже елегантно спроектованою мовою, і вирішив продовжувати її вивчення. Коли я зупинявся на чомусь, ChatGPT допомагав мені розібратися в деяких концепціях Rust, таких як lifetime, або в частинах перетворення типів BSON, тому я б рекомендував інвестувати час в навчання Rust разом з Python.

Основні висновки та підсумок:

Корисна комбінація Python + Rust: якщо ви вже знаєте Python, додавання знань про Rust розширить вашу сферу впливу, а також може покращити розуміння Python та якість вашого коду.

Інструменти: Як ми бачили, важливо знати, які інструменти використовувати, такі як менеджери пакетів (cargo, UV), бібліотека pyo3, як структурувати проєкт, як налаштовувати pyproject.toml та cargo.toml.

Перетворення типів: pyo3 вже надає примітиви для перетворення типів, але коли мова йде про складні типи, це вимагає додаткової роботи та глибшого розуміння API pyo3.

Додатковий бонус

Один епізод подкасту Voice of the Developer був дуже приємним для мене, цей епізод розповідає про те, як реалізовано pyo3, внутрішні аспекти Python, як здійснюються перетворення і т.д...
перегляньте тут

Джерела:

Перекладено з: How I published my 1st Rust-Python binding package

Leave a Reply

Your email address will not be published. Required fields are marked *