ГлавнаяБлогПортирование Python на Rust: 54 теста, 0 правок, 0 unsafe
Python

Портирование Python на Rust: 54 теста, 0 правок, 0 unsafe

Как портировать Python-библиотеку на Rust с сохранением тестов: 54 теста без правок, 0 unsafe, 24 500 дифференциальных проверок. Узнайте методологию и лайфхаки.

Al
Редакция Algolitalgolit.ru
12 мин чтения6 августа 2026 г.

Портирование Python на Rust: как пройти 54 теста без единой правки

Представьте: нужно переписать Python-библиотеку на Rust так, чтобы оригинальный набор тестов прошёл без изменений. Звучит как вызов? Именно это я сделал за 72 часа на хакертоне Port Mortem 2026. Результат — 54 теста, 16 пропусков, 586 подтестов, ноль правок тестов и ноль небезопасного кода. В этой статье я расскажу, как мне это удалось, какие грабли я собрал и как вы можете применить этот подход в своих проектах.

Ключевое ограничение: тесты должны пройти без изменений

Правила хакертона были жёсткими: оригинальный набор тестов должен быть пройден без модификаций, а его хэш фиксируется на старте. Это brutal constraint, но именно оно заставило принять центральное архитектурное решение: я создал PyO3/maturin расширение с именем semantic_version. В тестовом виртуальном окружении import semantic_version резолвится в мой Rust-код, поэтому тесты выполняются байт-в-байт как написано. Никаких заглушек, никаких правок тестов.

# Сборка и запуск тестов одной командой
$ make
VIRTUAL_ENV=... maturin develop
pytest tests/original/ -q
54 passed, 16 skipped, 586 subtests passed in 0.37s

16 пропусков — это тесты Django, которые пропускаются и в оригинале (Django не установлен). Это паритет, а не исключение.

Методология: сначала факты, потом порт

Самый быстрый способ провалить порт — портировать свои предположения. Python-библиотека semantic_version полна намеренных особенностей, поэтому до написания кода я исследовал оригинал и зафиксировал его точное поведение: AST-структуры, результаты сравнения, тексты ошибок. Это стало спецификацией.

Вот несколько неочевидных вещей, которые я выявил:

  • __eq__ включает build-метаданные, а сравнение — нет. Version("1.0.0+a") == Version("1.0.0+b") даёт False, но ни одна версия не больше и не меньше другой. Это нарушает контракт Rust Ord (a == b ⟺ cmp(a,b) == Equal), поэтому я убрал Ord из Version и добавил явные методы precedence_lt/le/gt/ge.
  • __ne__ сравнивает сырые кортежи, а не !eq — это пережиток Python 2, где частичная и не-частичная версии могут быть eq, но ne.
  • Сообщения об ошибках используют одинарные кавычки (Invalid version string: 'garbage') — соответствует Python-формату %r.
  • Spec — это просто LegacySpec. Один класс, два имени. В биндинге я выставил один pyclass и алиас.

Самые сложные 40 строк: npm-расширение prerelease

npm-диапазоны с prerelease-границей не раскрываются в простой интервал. >=1.0.0-rc.1 <2.0.0 превращается в двухветвевой AnyOf:

AnyOf(
  AllOf(<1.0.1 [always],  >=1.0.0-rc.1 [same-patch]),  # prerelease-ветка
  AllOf(>=1.0.0 [same-patch], <2.0.0 [same-patch])     # release-ветка
)

Моя первая версия упростила это и молча разошлась на спеках, объединённых через || с одной группой. Именно дифференциальный фаззер поймал это расхождение (подробнее ниже).

Доказательный слой: фаззинг и проверки

Прохождения тестов недостаточно — они покрывают только то, что авторы додумались проверить. Поэтому я построил доказательный слой:

  • Дифференциальный фаззинг: 49 сидов × 500 пар = 24 500 случайных (версия, спека) входов, прогнанных через оригинальный Python и Rust-биндинг, сравнение по parse/match/compare/str/repr/hash. 0 жёстких расхождений. (1 619 мягких различий — только в тексте ошибок, обе стороны бросают ValueError — задокументировано.)
  • Краш-фаззинг: 2 554 822 прогона libFuzzer над произвольными байтами → 0 паник.
  • Ноль unsafe: весь порт на безопасном Rust; grep -rn unsafe src/ пуст.
  • Журнал решений: 20 записей (DECISIONS.md, D00–D19), каждое нетривиальное расхождение: поведение Python → выбор Rust → обоснование → компромиссы → влияние на тесты.

Что поймал фаззер (честный раздел)

Дифференциальный фаззер нашёл 8 скрытых багов — все в моём порте, ни одного в оригинале. Включая 18 мест переполнения u64 (в Python есть bigints, в Rust нет), которые я закрыл через saturating_add, проблему с пустым prerelease, wildcard-гейты ~*/^* и случай с пустой группой при ||.

Я не заявляю бонус «ловца багов»: оригинальная библиотека была корректна, и моя задача — сойтись к ней. Но то, что фаззер превратил мои слепые зоны в список исправлений, — именно поэтому дифференциальное тестирование — единственный значимый оракул.

Бенчмарки без прикрас

На облачной ВМ хакертона (16 ГБ RAM, 2 физических / 4 логических ядра):

  • ~9× суммарное ускорение, 60× на npm-спеках, ~11× на парсинге
  • 21% ниже пиковый RSS (12.5 МБ против 15.9 МБ)

Честное предупреждение: путь precedence_key в PyO3 тянет суммарный показатель вниз из-за оверхэда Python-кортежей; нативный precedence работает ~386 нс p50. Бенчмарки только по пропускной способности — маркетинг. Распределения и сопутствующие факторы — инженерия.

Что бы я сказал себе в нулевой час

  • Сначала исследуй, потом портируй. Оригинал — спецификация, а не ваша память о нём.
  • Позволь фаззеру спорить с тобой. Он найдёт случаи, которые тесты не воображали.
  • Честность — это фича. Судьи доверяют «94% и вот почему» больше, чем «100%», которые не воспроизводятся.
  • ИИ-агент — усилитель, только если человек проверяет каждый коммит. Мультимодельный подход, единый автор, ревью всего.

Попробуйте сами

git clone https://github.com/rahulgupta0-dev/semanticversion-rs
cd semanticversion-rs && make  # собирает и запускает ОРИГИНАЛЬНЫЕ тесты против Rust

3 683 строки безопасного Rust, 20 решений, одна команда, чтобы убедиться. Занял ли я призовое место или нет — это самая тщательно проверенная вещь, которую я выпустил за 72 часа.

#rust#портирование#тестирование#фаззинг#PyO3
Al
Редакция Algolit

Пишем про алгоритмы, подготовку к собеседованиям и карьеру в IT — так, чтобы было понятно и полезно.

Хочешь закрепить знания на практике?

Решай задачи на Algolit — интерактивная платформа для обучения

Начать бесплатно →
Портирование Python на Rust: 54 теста, 0 правок, 0 unsafe | Algolit