Как портировать Python-библиотеку на Rust с сохранением тестов: 54 теста без правок, 0 unsafe, 24 500 дифференциальных проверок. Узнайте методологию и лайфхаки.
Представьте: нужно переписать 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.37s16 пропусков — это тесты Django, которые пропускаются и в оригинале (Django не установлен). Это паритет, а не исключение.
Самый быстрый способ провалить порт — портировать свои предположения. Python-библиотека semantic_version полна намеренных особенностей, поэтому до написания кода я исследовал оригинал и зафиксировал его точное поведение: AST-структуры, результаты сравнения, тексты ошибок. Это стало спецификацией.
Вот несколько неочевидных вещей, которые я выявил:
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.!eq — это пережиток Python 2, где частичная и не-частичная версии могут быть eq, но ne.Invalid version string: 'garbage') — соответствует Python-формату %r.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-ветка
)Моя первая версия упростила это и молча разошлась на спеках, объединённых через || с одной группой. Именно дифференциальный фаззер поймал это расхождение (подробнее ниже).
Прохождения тестов недостаточно — они покрывают только то, что авторы додумались проверить. Поэтому я построил доказательный слой:
ValueError — задокументировано.)grep -rn unsafe src/ пуст.Дифференциальный фаззер нашёл 8 скрытых багов — все в моём порте, ни одного в оригинале. Включая 18 мест переполнения u64 (в Python есть bigints, в Rust нет), которые я закрыл через saturating_add, проблему с пустым prerelease, wildcard-гейты ~*/^* и случай с пустой группой при ||.
Я не заявляю бонус «ловца багов»: оригинальная библиотека была корректна, и моя задача — сойтись к ней. Но то, что фаззер превратил мои слепые зоны в список исправлений, — именно поэтому дифференциальное тестирование — единственный значимый оракул.
На облачной ВМ хакертона (16 ГБ RAM, 2 физических / 4 логических ядра):
Честное предупреждение: путь precedence_key в PyO3 тянет суммарный показатель вниз из-за оверхэда Python-кортежей; нативный precedence работает ~386 нс p50. Бенчмарки только по пропускной способности — маркетинг. Распределения и сопутствующие факторы — инженерия.
git clone https://github.com/rahulgupta0-dev/semanticversion-rs
cd semanticversion-rs && make # собирает и запускает ОРИГИНАЛЬНЫЕ тесты против Rust3 683 строки безопасного Rust, 20 решений, одна команда, чтобы убедиться. Занял ли я призовое место или нет — это самая тщательно проверенная вещь, которую я выпустил за 72 часа.
Хочешь закрепить знания на практике?
Решай задачи на Algolit — интерактивная платформа для обучения
Начать бесплатно →