Написання та публікація модуля Python мовою Rust

12 хв. читання

Поговоримо про нелегкий шлях написання бібліотеки procmaps для Python мовою Rust. Для прив'язок в ній застосовано PyO3, а для керування збіркою — maturin (а також для wheel-пакування, що сумісне з manylinux1).

Код бібліотеки розміщено на GitHub, тож її можна встановити звідти за допомогою сучасного Python (3.5+) pip(1) без встановлення Rust на ваш пристрій:

pip3 install procmaps

Додаємо procmaps

procmaps — це надзвичайно маленька бібліотека Python, котра спирається на таку ж крихітну Rust-бібліотеку(2).

Вона лише аналізує файли з асоціативними масивами (map), які відомі своїм розташуванням у procfs на Linux (3), у списку об'єктів Map. Кожен асоціативний масив має основні атрибути у виділеній ділянці пам'яті.

За їхніми Python-атрибутами:

import os
import procmaps

# також: from_path, from_str
# N.B.: названо map_ замість map, щоб уникнути збігу з функцією map
map_ = procmaps.from_pid(os.getpid())[0]

map_.begin_address  # початкова адреса для визначеної ділянки
map_.end_address    # кінцева адреса для визначеної ділянки
map_.is_readable    # чи читабельна визначена ділянка?
map_.is_writable    # чи доступна для запису визначена ділянка?
map_.is_executable  # чи доступна для запуску визначена ділянка?
map_.is_shared      # чи застосовується визначена ділянка в інших процесах?
map_.is_private     # чи є приватною визначена ділянка (напр., copy-on-write)?
map_.offset         # зміщення до джерела ділянки, з якого вона походить
map_.device         # суфікс (major, minor) пристрою, на якому розміщено джерело ділянки
map_.inode          # inode джерела ділянки
map_.pathname       # поле «pathname» ділянки, або None для анонімних асоціативних масивів

Важливо: окрім імпорту та виклику os.getpid(), цей код надсилає виклики безпосередньо у скомпільований код Rust.

Мотивація створення

Причин створення procams було дві:

Перша: під час аналізу програм та дослідження інструментів щоразу потрібно отримувати дані про розподіл пам'яті програми, яку ми запускаємо або хочемо запустити. Зазвичай це означає, що потрібно відкрити /proc/<pid>/maps, написати парсер ad-hoc, отримати потрібне поле, а потім це все поєднати для роботи.

Повторюваність цих дій і стала головною причиною для створення цієї маленької окремої бібліотеки Rust:

  • Формат асоціативних масивів (map) рядково-орієнтований, до нього майже ніколи не вносяться зміни та він не має двозначностей (4). Rust має багато високоякісних бібліотек PEG (Parsing Expression Grammars) та поєднань бібліотек парсерів, які чудово пристосовані для цього завдання.

  • Написання парсерів ad-hoc нам не підходить, особливо коли їх написано на C або C++.

  • Малесенька бібліотека з невеликим API не надто впливатиме на інші мови (зокрема на C та C++).

Друга причина: автор бібліотеки якийсь час вивчав мову Rust і можливості її застосування. Взаємодія з іншою мовою (особливо з кардинально іншою семантикою пам'яті, наприклад, з Python) — непоганий спосіб спробувати свої сили.

Структура

Насправді модуль procmaps — це старий-добрий крейт у Rust. Відмінності є лише у Cargo.toml:

[lib]
crate-type = ["cdylib"]

[package.metadata.maturin]
classifier = [
  "Programming Language :: Rust",
  "Operating System :: POSIX :: Linux",
]

Інші налаштування у package.metadata.maturin доступні, наприклад, для керування залежностями у Python, але для procmaps вони не потрібні. Докладніше тут.

Як код наш крейт структурований у вигляді звичайної бібліотеки Rust. PyO3 вимагає лише вкраплень синтаксичного цукру, щоб бібліотека працювала з Python.

Модулі

Модулі Python створюються за допомогою декоратора функції Rust #[pymodule].

Потім ця функція застосовує функції аргументу PyModule, потрібні для завантаження функцій та класів модуля.

Наприклад, ось увесь цей модуль procmaps, видимий для Python:

#[pymodule]
fn procmaps(_py: Python, m: &PyModule) -> PyResult<()> {
    m.add_class::<Map>()?;
    m.add_wrapped(wrap_pyfunction!(from_pid))?;
    m.add_wrapped(wrap_pyfunction!(from_path))?;
    m.add_wrapped(wrap_pyfunction!(from_str))?;

    Ok(())
}

Функції

Функції рівня модуля створювати просто — це звичайні функції Rust, позначені #[pyfunction]. Вони завантажуються в модулі через add_wrapped та wrap_pyfunction!. Крім того, їх можна створювати через означення модуля (наприклад, у вкладенні через #[pymodule]) функції за допомогою декоратора #[pyfn].

Функції, видимі для Python, повертають PyResult<T>, де T імплементує IntoPy<PyObject>. PyO3 допомагає впровадити цю особливість для багатьох основних типів (вичерпний перелік тут). Це включає Option<T>, що дає змогу легко перетворювати функції рівня Rust, котрі повертають Options, у функції рівня Python, які можуть повернути None.

procmaps їх не використовує, але PyO3 також підтримує варіадичні аргументи та аргументи ключових слів. Докладніше тут.

Ось проста функція Python, яка виконує ділення цілого числа, повертаючи None, якщо запитано ділення на нуль:

#[pyfunction]
fn idiv(dividend: i64, divisor: i64) -> PyResult<Option<i64>> {
  if divisor == 0 {
    Ok(None)
  } else {
    Ok(Some(dividend / divisor))
  }
}

Класи

Як видно з визначення модуля, класи завантажуються у модулі за допомогою функції add_class.

Ними, як і модулями, керує переважно один декоратор, цього разу це struct із Rust. Такий вигляд має повне визначення класу procmaps.Map:

#[pyclass]
struct Map {
    inner: rsprocmaps::Map,
}

procmaps вони не потрібні, але звичайні гетери та сетери можна додавати до частин класу через #[pyo3(get, set)]. Наприклад, так можна створити клас Point:

#[pyclass]
struct Point {
  #[pyo3(get, set)]
  x: i64,
  #[pyo3(get, set)]
  y: i64,
}

…у Python це можна зробити так:

# get_unit_point не показано
from pointlib import get_unit_point

p = get_unit_point()
print(p.x, p.y)

p.x = 100
p.y = -p.x
print(p.x, p.y)

Використання #[pyclass] у Foo автоматично додає IntoPy<PyObject> для Foo — а це спрощує повернення кастомних класів з будь-якої функції або користувацького методу.

Користувацькі (member) методи

Ми знаємо, що видимі для Python класи, визначаються за допомогою #[pyclass] у struct Rust. Так само видимі для Python користувацькі методи оголошуються через атрибут #[pymethods] в impl Rust для цих структур.

Як і функції, користувацькі методи повертають PyResult<T>:

#[pymethods]
impl Point {
  fn invert(&self) -> PyResult<Point> {
    Ok(Point { x: self.y, y: self.x})
  }
}

…що дає змогу зробити так:

# get_unit_point не показано
from pointlib import get_unit_point

p = get_unit_point()
p_inv = p.invert()

Типово PyO3 забороняє створювати класи з Rust у коді Python. Щоб дозволити це, потрібно лише додати функцію з атрибутом #[new] до блоку #[pymethods] impl. Так ми створимо Python-метод __new__, а не __init__, який PyO3 не підтримує (5).

Наприклад, такий вигляд має конструктор для нашого класу Point:

#[pymethods]
impl Point {
  #[new]
  fn new(x: i64, y: i64) -> Self {
    Point { x, y }
  }
}

…що дозволяє:

from pointlib import Point

p = Point(100, 0)
p_inv = p.invert()
assert p.y == 100

Винятки та поширені помилки

Більшість видимих для Python функцій та методів повертають PyResult<T>.

Половина помилок PyResult — це PyErr. Ці значення відомі як винятки Python. pyo3::exceptions модуля мають структури, паралельні звичайним виняткам Python. Кожна з них надає функцію py_err(String) для створення відповідного PyErr.

Створення абсолютно нового винятку на рівні Python займає один рядок із макросом create_exception!.

Ось як procmaps створює виняток procmaps.ParseError, що утворюється на основі стандартного класу Python Exception:

use pyo3::exceptions::Exception;

// N.B.: Перший аргумент — це назва модуля,
// напр. функція задекларована через #[pymodule].
create_exception!(procmaps, ParseError, Exception);

Отже, розміщення типів Error Rust PyErr — це так само просто, як impl std::convert::From<ErrorType> для PyErr.

Ось як procmaps перетворює деякі помилки у звичайний Python IOError, а інші у кастомний виняток procmaps.ParseError:

// N.B.: Новий тип тут необхідний лише тому, що Error надходить із
// зовнішнього крейта (rsprocmaps).
struct ProcmapsError(Error);
impl std::convert::From<ProcmapsError> for PyErr {
    fn from(err: ProcmapsError) -> PyErr {
        match err.0 {
            Error::Io(e) => IOError::py_err(e.to_string()),
            Error::ParseError(e) => ParseError::py_err(e.to_string()),
            Error::WidthError(e) => ParseError::py_err(e.to_string()),
        }
    }
}

Компіляція та розподіл

Тож cargo build створює спільний об'єкт, який завантажується через Python.

На жаль, для цього використовується конвенція назв cdylib. Це означає, що cargo build створює libprocmaps.so для procmaps, а не одну з конвенцій назв, які Python розпізнає під час пошуку $PYTHONPATH (6).

Саме тут ми застосовуємо maturin. Після встановлення єдина у crate root збірка maturin build створює pip wheel з відповідною назвою у target/wheels.

Навіть краще: maturin develop встановить скомпільований модуль одразу в поточне віртуальне середовище, а це спростить локальну розробку:

$ python3 -m venv env
$ source env/bin/activate
(env) $ pip3 install maturin
(env) $ maturin develop
$ python3
> import procmaps

procmaps має зручний Makefile, який може все це огорнути. Запуск скомпільованого модуля локально — це лише один крок до make develop.

Дистрибуція натомість дещо складніша: maturin develop утворює wheel-пакування, сумісні з локальною машиною. Але він вимагає додаткових обмежень на рівні символів та посилань, щоб бінарне wheel-пакування працювало для багатьох версій Linux та дистрибутивів (7).

Як правило, для дотримання цих обмежень потрібен один із двох способів:

  1. Пакунки компілюються у бінарні wheel-пакування, а потім перевіряються (та можуть бути виправлені) за допомогою auditwheel PyPA перед запуском.
  2. Пакунки компілюються у бінарні wheel-пакування у середовищі виконання із повним контролем, наприклад, у Docker-контейнери PyPa manylinux.

Дистрибуція за допомогою maturin застосовує другий підхід. Розробники maturin створили збиральний контейнер Rust зі звичайного контейнера PaPa's manylinux, та зробили збірки повністю сумісними (знову ж таки з crate root):

# додатково: запустіть `build --release` для оптимізованих для випуску збірок
$ docker run --rm -v $(pwd):/io konstin2/maturin build

Як і звичайний maturin build, ця команда перекидає скомпільовані wheel-пакування у target/wheels. Оскільки вона запускається всередині стандартного контейнера manylinux, вона автоматично створює wheel-пакування для багатьох версій Python (Python 3.5-3.8 на момент написання статті).

Отже, дистрибуція у PyPl — це так само просто, як twine upload target/wheels/* або maturin publish. Зараз procmaps використовує першу, оскільки випуски обробляються через GitHub Actions, застосовуючи чудову дію PyPI gh-action-pypi-publish.

Перемога! Повністю написаний на Rust модуль Python можна встановити у більшості дистрибутивів Linux без будь-яких залежностей Rust. Навіть метадані non-maturin у Cargo.toml поширюються правильно!

Написання та публікація модуля Python мовою Rust

Підсумуємо

Під час роботи над procmaps автор бібліотеки зіткнувся лише з однією невеликою проблемою з procmaps. Він спробував додати метод Map.__contains__ , аби можна було перевірити включення за допомогою протоколу in, наприклад:

fn __contains__(&self, addr: u64) -> PyResult<bool> {
    Ok(addr >= self.inner.address_range.begin && addr < self.inner.address_range.end)
}

…проте, навіть за допомогою прямого виклику, це не спрацювало.

>>> 4194304 in map_
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: argument of type 'Map' is not iterable

>>> map_.__contains__(4194304)
True

Можливо, це спричинено моделлю даних Python, з якою автору не вдалося розібратися. Однак на Reddit йому підказали правильну відповідь. Він підготував нову версію procmaps, що показує протокол __contains__ в дії.

Загалом, писати модуль Python на Rust може бути доволі цікаво. Автору цих рядків не довелося писати жодного рядка на Python (навіть спеціальних конфігурацій для Python), доки він не додав юніт-тести. Як pyO3, так і maturin чудово розроблені, а завдяки PyPA і середовищам manylinux робити збірки сумісними стало зовсім легко.

Примітки:

  1. Наразі лише для x86_64. Хоч перепон для роботи з іншими архітектурами й немає. Річ лише у під'єднанні через CI, що відрізняється від GitHub Actions.
  2. Першочерговою ціллю створення бібліотеки Rust було вивчити Pest у простому форматі. Як виявилось, на Crates вже є високоякісний еквівалентний пакунок.
  3. Linux не створює procfs, але інші системи Unix не надають /proc/<pid>/maps. Скидається на те, що лише FreeBSD надає файл /proc/<pid>/map зі схожим призначенням.
  4. За винятком поля «pathname»; докладніше у документації proc(5).
  5. Імовірно, тому що у Rust не закладено поняття «створеного, але неініціалізованого» об'єкта. Вони завжди поєднані.
  6. Документації мало, але strace -m procmaps вказує на те, що прийнятними форматами є procmaps.cpython-XX-target-triple.so, procmaps.so та procmapsmodule.so.
  7. Вони відомі як обмеження «manylinux» та задокументовані у PEP 513 («manylinux1»), 571 («manylinux2010»), 599 («manylinux2014»), 600 та, можливо, інших.
Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.7K
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація