Add homework 01 guarantees assignment

This commit is contained in:
2026-09-10 13:22:31 +03:00
parent 16a7a0e469
commit e29c7eb39e
17 changed files with 5613 additions and 1 deletions
+2 -1
View File
@@ -6,8 +6,9 @@
- [Канал](https://t.me/+jmiH8ATByBxjYmM6) и [чат](https://t.me/+7K6jQRKvhMZjMDky) курса
- [Репозиторий с материалами курса](https://distsys.ru/course/hse-2026/)
- [Таблица с оценками](https://docs.google.com/spreadsheets/d/1bas6MLplMmvbgl4X_syl4Tv5LPPT2pemjmDgWjs2KNo/)
- [Записи занятий](https://disk.yandex.ru/d/h1-EWq4eNgP4_g)
- [Таблица с оценками](https://docs.google.com/spreadsheets/d/1bas6MLplMmvbgl4X_syl4Tv5LPPT2pemjmDgWjs2KNo/)
- [Политика использования LLM](llm-policy.md)
- Форма для фидбека по курсу
## Занятия и преподаватели
+130
View File
@@ -0,0 +1,130 @@
# Гарантии доставки
В этом задании вам предстоит реализовать различные гарантии доставки сообщений в распределённой системе.
В нашей системе есть два узла, и нам требуется организовать одностороннюю передачу текстовых сообщений между выполняющимися на узлах процессами. Процесс _Sender_ будет принимать сообщения от локального пользователя _S_ и отправлять их по сети процессу _Receiver_. Процесс _Receiver_ будет принимать сообщения от _Sender_ и доставлять их локальному пользователю _R_. Под доставкой подразумевается отправка локального сообщения, идентичного исходному сообщению от _S_.
Вам необходимо написать четыре реализации _Sender_ и _Receiver_, обеспечивающие следующие гарантии доставки сообщений:
1. _Не более одного раза (at most once)._ Каждое сообщение от _S_ должно быть или доставлено _R_ ровно один раз или не доставлено вовсе. Иными словами, нельзя допускать повторные доставки сообщений.
2. _Не менее одного раза (at least once)._ Каждое сообщение от _S_ должно быть доставлено _R_, при этом допускаются повторы.
3. _Ровно один раз (exactly once)._ Каждое сообщение от _S_ должно быть доставлено _R_ ровно один раз, то есть повторы не допускаются.
4. _Ровно один раз и с сохранением порядка (exactly once + ordered)._ Каждое сообщение от _S_ должно быть доставлено _R_ ровно один раз и в порядке их отправки _S_.
Процессы могут взаимодействовать друг с другом путём обмена сообщениями через сетевой транспорт со следующими характеристиками: отдельное сообщение может быть потеряно, доставленные сообщения не искажаются, сообщения могут дублироваться, порядок их получения не гарантируется, все получаемые сообщения были кем-то отправлены (нет сообщений «из воздуха»). При этом сеть обладает свойством fair-loss: если отправитель неограниченно повторяет передачу одного и того же сообщения, хотя бы одна его копия рано или поздно достигнет получателя. Это предположение действует для передачи в обоих направлениях и соблюдается в тестах. Отказы узлов в данном задании отсутствуют. Все указанные гарантии рассматриваются только в рамках описанной модели.
Независимо от типа гарантий, если сеть ведёт себя надёжно (нет потерь сообщений), все сообщения от _S_ должны доставляться _R_. Это исключает, например, тривиальную реализацию гарантии 1, которая не отправляет ничего по сети.
Ваша реализация не должна делать предположений об уникальности содержимого доставляемых сообщений. Например, в разные моменты времени могут быть отправлены два сообщения с идентичным текстом. В этом случае для гарантии 2 требуется доставить эти сообщения пользователю не менее двух раз, а для гарантий 3 и 4 — ровно два раза. Также не следует делать предположений о размере сообщений — он может быть произвольным.
Несложно заметить, что реализация последней, самой сильной гарантии покрывает первые три гарантии. Тем не менее мы просим вас реализовать каждую гарантию отдельно. Так вы наглядно увидите, какие дополнительные ресурсы и накладные расходы требуются для поддержки той или иной гарантии. На практике не всегда требуются самые сильные гарантии: например, порядок доставки может быть неважен и «платить» за него будет расточительно. Поэтому, если вы реализуете только последнюю гарантию, мы не зачтём вам остальные.
На максимальный балл требуется также оптимизировать ресурсы, потребляемые для обеспечения каждой из гарантий. Во-первых, это объём памяти, используемой процессами, то есть размер хранимого ими состояния. Overhead-тесты сравнивают потребление памяти с заданными порогами: если бессрочно хранить все когда-либо обработанные сообщения или их идентификаторы, решение в эти пороги не уложится. При этом актуальные данные, например неподтверждённые сообщения, хранить можно и в некоторых гарантиях необходимо. Постарайтесь удалять лишнюю или неактуальную информацию (например, если доставлены все 100 предыдущих сообщений, то так ли необходимо хранить все 100 идентификаторов сообщений?). При оптимизации памяти учитывайте не только количество хранимых элементов, но и накладные расходы выбранного представления данных. Во-вторых, это число и суммарный объём сообщений, передаваемых по сети между _Sender_ и _Receiver_. Постарайтесь не передавать по сети лишние данные и не отправлять слишком много сообщений, особенно если они могут быть отброшены получателем, то есть переданы по сети зря. В процессе оптимизации вы должны осознать возникающие при такой оптимизации компромиссы между потреблением памяти, нагрузкой на сеть и скоростью доставки сообщений.
## Реализация
Для реализации и тестирования решения используется учебный фреймворк AnySystem (см. материалы первого семинара). В папке `solution` размещена заготовка для решения [guarantees.py](solution/guarantees.py). Вам надо доработать реализации классов `...Sender` и `...Receiver` для каждой гарантии так, чтобы они проходили все тесты.
Ваши реализации процессов AnySystem должны соблюдать правило [изоляции процессов](../readme.md#изоляция-процессов-anysystem): изменяемое состояние каждого процесса должно храниться только в `self`, а общая изменяемая память между процессами запрещена. Все взаимодействия между процессами должны происходить через сообщения, как и в реальной распределённой системе.
### Sender
Сообщения от _S_ передаются процессу с помощью локальных сообщений, см. метод `on_local_message()`. Все сообщения имеют тип `MESSAGE` и одинаковую структуру: в единственном поле `text` содержится строка с текстом сообщения. Для взаимодействия с _Receiver_ вы можете использовать сообщения произвольного типа и структуры. Приходящие от _Receiver_ сообщения следует обрабатывать в методе `on_message()`. Также вы можете устанавливать таймеры в любом из методов и обрабатывать их наступление в `on_timer()`.
### Receiver
Данный процесс не принимает локальные сообщения, поэтому метод `on_local_message()` не используется. Сетевые сообщения следует обрабатывать в методе `on_message()`. Также вы можете устанавливать таймеры в любом из методов и обрабатывать их наступление в `on_timer()`.
Важно правильно реализовать доставку сообщений локальному пользователю _R_, иначе тесты не будут проходить. Для этого вы должны отправить локальное сообщение с помощью метода `ctx.send_local()`. Сообщение должно быть полностью идентично исходному сообщению, принятому _Sender_ от пользователя _S_, то есть иметь тот же тип `MESSAGE` и поле `text` с тем же значением. Других полей в сообщении быть не должно.
## Оценивание
Компоненты задачи и их вклад в оценку:
- Корректная реализация гарантии _at most once_ - 2 балла
- проходят все тесты на эту гарантию без "OVERHEAD..."
- Корректная реализация гарантии _at least once_ - 2 балла
- проходят все тесты на эту гарантию без "OVERHEAD..."
- Корректная реализация гарантии _exactly once_ - 2 балла
- проходят все тесты на эту гарантию без "OVERHEAD..."
- Корректная реализация гарантии _exactly once + ordered_ - 2 балла
- проходят все тесты на эту гарантию без "OVERHEAD..."
- Оптимизация потребляемых ресурсов - 2 балла*
- проходят тесты "OVERHEAD..." для всех гарантий (1 балл)
- в отчёте описаны возникающие при оптимизации компромиссы и обоснованы используемые подходы (1 балл)
- *засчитывается только при успешном выполнении всех предыдущих компонентов
Краткий отчёт с описанием решения в `solution/readme.md` обязателен и сдаётся вместе с решением до дедлайна. Без отчёта автоматические тесты запускаются, но защита не проводится и **решение не засчитывается**.
Автоматический `SCORE`, который выводят тесты, составляет не более 9 баллов. Ещё 1 балл за описание и обоснование оптимизаций выставляется отдельно по отчёту.
Штрафы:
- Для гарантии A используется реализация более сильной гарантии B - минус 1 балл за гарантию A
## Тестирование
### Локальное тестирование
Тесты находятся в папке `tests`. Есть два варианта их запуска.
Рекомендуемый вариант запуска тестов - через готовый Docker-образ. В этом случае используемое окружение будет аналогично тестирующей системе. Убедитесь, что на вашей машине установлен [Docker Engine](https://docs.docker.com/engine/install/) (можно использовать [Docker Desktop](https://docs.docker.com/desktop/)). Для запуска тестов выполните команду:
```commandline
docker run --pull always --rm -t -v ./solution:/solution distsys.ru/course/guarantees:latest [ЗДЕСЬ МОЖНО УКАЗАТЬ ОПЦИИ]
```
Для запуска полного набора тестов с теми же параметрами, что и в тестирующей системе, выполните команду:
```commandline
docker run --pull always --rm -t -v ./solution:/solution distsys.ru/course/guarantees:latest -m 100 -c -o
```
Вы также можете запустить тесты, скомпилировав их локально с помощью компилятора Rust. Такой вариант может быть удобен, если вы хотите лучше изучить или доработать тесты. Убедитесь, что на вашей машине установлен [Rust](https://www.rust-lang.org/tools/install). Скомпилируйте тесты с помощью команды `cargo install --locked --path tests`. Для запуска тестов выполните команду:
```commandline
distsys-guarantees [ЗДЕСЬ МОЖНО УКАЗАТЬ ОПЦИИ]
```
Доступные опции тестов можно посмотреть с помощью флага `-h`. Опишем наиболее важные из них:
- Флаг `-d` включает вывод трасс - последовательностей событий во время выполнения каждого из тестов. Его рекомендуется использовать при отладке решений.
- Опция `-m` задает количество запусков рандомизированных тестов (chaos monkey). Значение по умолчанию - 0. Как только ваше решение будет проходить основные тесты, установите значение в 10 и убедитесь, что эти тесты проходят. Далее можно проверить решение на 100 запусках (`-d` лучше убрать для скорости) - такое значение используется в тестирующей системе. (Обратите внимание, что эти тесты хоть и рандомизированные, но детерминированные - при одном значении seed результат будет всегда одинаковый. Так что не стоит пытаться заново тестировать то же самое решение, надеясь что оно вдруг пройдет.)
- Флаг `-c` включает тесты на model checking (см. первый семинар), по умолчанию они выключены. Как только ваше решение будет проходить основные тесты, добавьте этот флаг и убедитесь, что эти тесты также проходят.
- Флаг `-o` включает тесты на потребление ресурсов (памяти и сети), по умолчанию они выключены. В этих тестах измеряются и выводятся максимальное потребление памяти объектами _Sender_ и _Receiver_, число переданных по сети сообщений, их суммарный объем (traffic) и отношение числа исходных сообщений к времени работы вашей реализации (throughput). Полученные значения сравниваются с пороговыми значениями, в которые укладывается с запасом авторское решение. Как только ваше решение будет проходить основные тесты, chaos monkey и model checking, включите эти тесты и при необходимости займитесь оптимизацией решения.
- Опция `-t` позволяет прогнать только один конкретный тест, указав его имя (в точности как оно выводится в консоли, например `[AT MOST ONCE] NORMAL`).
- Опция `-g` позволяет прогнать только тесты для одной из гарантий, указав её сокращение (`AMO`, `ALO`, `EO`, `EOO`).
- Опция `-s` позволяет изменить используемый random seed (см. первый семинар). Можно использовать для дополнительной проверки вашего решения. В тестирующей системе используется значение по умолчанию (123).
Во время проверки решения в тестирующей системе используются опции `-m 100 -c -o` с лимитом времени в 5 минут. На авторском решении выполнение всех тестов с этими опциями занимает около 10 секунд.
Код тестов открыт и находится в `tests/src`. Вы можете обращаться к нему и использовать в своём решении информацию об условиях тестирования, например о минимальной и максимальной задержках в сети. При этом корректность решения не должна зависеть от конкретных значений задержек, вероятностей потери и дублирования сообщений: гарантии должны выполняться при любом поведении сети, соответствующем описанной выше модели.
Если вы найдете ошибки или требования из условий, которые не покрывают наши тесты, то вы можете получить за это бонусные баллы. Для этого надо включить в отчёт описание ситуации, которую не ловят тесты, добавив при необходимости пример решения с ошибкой. За это полагается 1 балл. Если вы также реализуете тесты, которые ловят найденную проблему, или хотя бы опишите их логику, то получите еще 1 балл.
### Проверка в тестирующей системе
Отправьте ваше решение в тестирующую систему следуя [инструкции](../readme.md) и дождитесь результатов.
## ЧаВо
**Как измеряется потребление памяти в тестах на overhead и что в него входит?**
См. [здесь](https://github.com/osukhoroslov/anysystem/blob/main/src/python/mod.rs#L282). Данной функции передаётся объект _Sender_ или _Receiver_. Измеряется потребление памяти структурами данных (атрибутами) внутри этих объектов. Функция вызывается периодически по ходу выполнения теста, и запоминается максимальное полученное значение, которое выводится в конце теста. Затраты на глобальные переменные и таймеры не учитываются.
**Что можно и что нельзя использовать для хранения состояния процесса?**
Можно и нужно использовать атрибуты объектов _Sender_ и _Receiver_.
Если вы претендуете на баллы за оптимизацию потребляемых ресурсов, также нельзя:
- хранить полный набор идентификаторов сообщений в виде битовых значений в числе или битовом наборе (bitset); тесты проверяют наличие в коде операторов битового сдвига,
- хранить содержимое сообщений в именах таймеров (допускается хранить номера сообщений),
- отправлять из процесса самому себе сообщения с его состоянием.
Эти способы будут рассматриваться как попытка обойти измерение ресурсов, и пункт с оптимизацией потребляемых ресурсов засчитан не будет. Авторское решение не использует подобных ухищрений и хранит всё состояние в атрибутах процессов.
Для прохождения тестов на overhead не требуются сторонние библиотеки типа numpy, сжатие и распаковка данных, специальные бинарные форматы и т.п. Авторское решение не использует ничего из перечисленного.
Если вы не понимаете, как пройти тесты без вышеописанных хаков, перечитайте условие, там есть намёк.
**Допускается ли падение тестов на overhead с другим random seed?**
Да. Пороги в тестах указаны не вообще для всех возможных выполнений (где потребление ресурсов может довольно сильно отличаться), а только для запусков с дефолтным seed.
@@ -0,0 +1,177 @@
from anysystem import Context, Message, Process
# AT MOST ONCE ---------------------------------------------------------------------------------------------------------
class AtMostOnceSender(Process):
def __init__(self, proc_id: str, receiver_id: str):
self._id = proc_id
self._receiver = receiver_id
def on_local_message(self, msg: Message, ctx: Context):
# receive message for delivery from local user
pass
def on_start(self, ctx: Context):
pass
def on_message(self, msg: Message, sender: str, ctx: Context):
# process messages from receiver here
pass
def on_timer(self, timer_name: str, ctx: Context):
# process fired timers here
pass
class AtMostOnceReceiver(Process):
def __init__(self, proc_id: str):
self._id = proc_id
def on_local_message(self, msg: Message, ctx: Context):
# not used in this task
pass
def on_start(self, ctx: Context):
pass
def on_message(self, msg: Message, sender: str, ctx: Context):
# process messages from receiver
# deliver message to local user with ctx.send_local()
pass
def on_timer(self, timer_name: str, ctx: Context):
# process fired timers here
pass
# AT LEAST ONCE --------------------------------------------------------------------------------------------------------
class AtLeastOnceSender(Process):
def __init__(self, proc_id: str, receiver_id: str):
self._id = proc_id
self._receiver = receiver_id
def on_local_message(self, msg: Message, ctx: Context):
# receive message for delivery from local user
pass
def on_start(self, ctx: Context):
pass
def on_message(self, msg: Message, sender: str, ctx: Context):
# process messages from receiver here
pass
def on_timer(self, timer_name: str, ctx: Context):
# process fired timers here
pass
class AtLeastOnceReceiver(Process):
def __init__(self, proc_id: str):
self._id = proc_id
def on_local_message(self, msg: Message, ctx: Context):
# not used in this task
pass
def on_start(self, ctx: Context):
pass
def on_message(self, msg: Message, sender: str, ctx: Context):
# process messages from receiver
# deliver message to local user with ctx.send_local()
pass
def on_timer(self, timer_name: str, ctx: Context):
# process fired timers here
pass
# EXACTLY ONCE ---------------------------------------------------------------------------------------------------------
class ExactlyOnceSender(Process):
def __init__(self, proc_id: str, receiver_id: str):
self._id = proc_id
self._receiver = receiver_id
def on_local_message(self, msg: Message, ctx: Context):
# receive message for delivery from local user
pass
def on_start(self, ctx: Context):
pass
def on_message(self, msg: Message, sender: str, ctx: Context):
# process messages from receiver here
pass
def on_timer(self, timer_name: str, ctx: Context):
# process fired timers here
pass
class ExactlyOnceReceiver(Process):
def __init__(self, proc_id: str):
self._id = proc_id
def on_local_message(self, msg: Message, ctx: Context):
# not used in this task
pass
def on_start(self, ctx: Context):
pass
def on_message(self, msg: Message, sender: str, ctx: Context):
# process messages from receiver
# deliver message to local user with ctx.send_local()
pass
def on_timer(self, timer_name: str, ctx: Context):
# process fired timers here
pass
# EXACTLY ONCE + ORDERED -----------------------------------------------------------------------------------------------
class ExactlyOnceOrderedSender(Process):
def __init__(self, proc_id: str, receiver_id: str):
self._id = proc_id
self._receiver = receiver_id
def on_local_message(self, msg: Message, ctx: Context):
# receive message for delivery from local user
pass
def on_start(self, ctx: Context):
pass
def on_message(self, msg: Message, sender: str, ctx: Context):
# process messages from receiver here
pass
def on_timer(self, timer_name: str, ctx: Context):
# process fired timers here
pass
class ExactlyOnceOrderedReceiver(Process):
def __init__(self, proc_id: str):
self._id = proc_id
def on_local_message(self, msg: Message, ctx: Context):
# not used in this task
pass
def on_start(self, ctx: Context):
pass
def on_message(self, msg: Message, sender: str, ctx: Context):
# process messages from receiver
# deliver message to local user with ctx.send_local()
pass
def on_timer(self, timer_name: str, ctx: Context):
# process fired timers here
pass
@@ -0,0 +1,5 @@
target
**/target
**/__pycache__
**/.pytest_cache
*.pyc
+990
View File
@@ -0,0 +1,990 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "anysystem"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01badd3bc0d042e4399f8f685c394a79a72e7f233b3d3b5eabb3b94734f4dbe"
dependencies = [
"colored",
"downcast-rs",
"dyn-clone",
"indexmap 2.14.0",
"lazy_static",
"log",
"ordered-float",
"pyo3",
"rand",
"rand_pcg",
"regex",
"rstest",
"serde",
"serde_json",
"simcore",
"sugars",
]
[[package]]
name = "assertables"
version = "3.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d84b91d63c67e6c0b1a74728dad0057c4a6124b74e671ff91b7fe9f8bbd671aa"
[[package]]
name = "atty"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
dependencies = [
"hermit-abi",
"libc",
"winapi",
]
[[package]]
name = "autocfg"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "cfg-if"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
[[package]]
name = "clap"
version = "3.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123"
dependencies = [
"atty",
"bitflags",
"clap_derive",
"clap_lex",
"indexmap 1.9.3",
"once_cell",
"strsim",
"termcolor",
"textwrap",
]
[[package]]
name = "clap_derive"
version = "3.2.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae6371b8bdc8b7d3959e9cf7b22d4435ef3e79e138688421ec654acf8c81b008"
dependencies = [
"heck 0.4.1",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "clap_lex"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2850f2f5a82cbf437dd5af4d49848fbdfc27c157c3d010345776f952765261c5"
dependencies = [
"os_str_bytes",
]
[[package]]
name = "colored"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.59.0",
]
[[package]]
name = "distsys-guarantees"
version = "0.1.0"
dependencies = [
"anysystem",
"assertables",
"clap",
"env_logger",
"indexmap 2.14.0",
"log",
"pyo3",
"rand",
"rand_pcg",
"sugars",
]
[[package]]
name = "downcast-rs"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]]
name = "dyn-clone"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "env_logger"
version = "0.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a12e6657c4c97ebab115a42dcee77225f7f482cdd841cf7088c657a42e9e00e7"
dependencies = [
"atty",
"humantime",
"log",
"regex",
"termcolor",
]
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "erased-serde"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e004d887f51fcb9fef17317a2f3525c887d8aa3f4f50fed920816a688284a5b7"
dependencies = [
"serde",
"typeid",
]
[[package]]
name = "futures"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
dependencies = [
"futures-channel",
"futures-core",
"futures-executor",
"futures-io",
"futures-sink",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-channel"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
dependencies = [
"futures-core",
"futures-sink",
]
[[package]]
name = "futures-core"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-executor"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]]
name = "futures-io"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "futures-sink"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
[[package]]
name = "futures-task"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-timer"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
[[package]]
name = "futures-util"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-macro",
"futures-sink",
"futures-task",
"memchr",
"pin-project-lite",
"pin-utils",
"slab",
]
[[package]]
name = "getrandom"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "glob"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280"
[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33"
dependencies = [
"libc",
]
[[package]]
name = "humantime"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
[[package]]
name = "indexmap"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
dependencies = [
"autocfg",
"hashbrown 0.12.3",
]
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
]
[[package]]
name = "itoa"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]]
name = "libc"
version = "0.2.175"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543"
[[package]]
name = "log"
version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]]
name = "memchr"
version = "2.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "ordered-float"
version = "4.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
dependencies = [
"num-traits",
"rand",
"serde",
]
[[package]]
name = "os_str_bytes"
version = "6.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1"
[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "portable-atomic"
version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "proc-macro-crate"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro-error"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [
"proc-macro-error-attr",
"proc-macro2",
"quote",
"syn 1.0.109",
"version_check",
]
[[package]]
name = "proc-macro-error-attr"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [
"proc-macro2",
"quote",
"version_check",
]
[[package]]
name = "proc-macro2"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
dependencies = [
"unicode-ident",
]
[[package]]
name = "pyo3"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4688ddedf473e32662b9b067670129a8afb8c18e351482c70d62ba4a88171e8b"
dependencies = [
"libc",
"once_cell",
"portable-atomic",
"pyo3-build-config",
"pyo3-ffi",
"pyo3-macros",
]
[[package]]
name = "pyo3-build-config"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f41027e41b4bd03f6e60f9f417fe24a6341a6bb744edd62b6f709f2a52ea30e9"
dependencies = [
"target-lexicon",
]
[[package]]
name = "pyo3-ffi"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e591a95526fead067432c3b3a33fc74770b87b1e04e73671090d9c2055a2b327"
dependencies = [
"libc",
"pyo3-build-config",
]
[[package]]
name = "pyo3-macros"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73225868fc1cd84eef2c3c230ddb91273bf1de46aeb8a4248da76d32a0924a1c"
dependencies = [
"proc-macro2",
"pyo3-macros-backend",
"quote",
"syn 2.0.106",
]
[[package]]
name = "pyo3-macros-backend"
version = "0.29.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "571575aa3749fa6216757dd47d2a3e7ef360f329a40f0666a9fbd14889024952"
dependencies = [
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
"rand_chacha",
"rand_core",
"serde",
]
[[package]]
name = "rand_chacha"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
dependencies = [
"getrandom",
"serde",
]
[[package]]
name = "rand_pcg"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59cad018caf63deb318e5a4586d99a24424a364f40f1e5778c29aca23f4fc73e"
dependencies = [
"rand_core",
]
[[package]]
name = "regex"
version = "1.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001"
[[package]]
name = "relative-path"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
[[package]]
name = "rstest"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afd55a67069d6e434a95161415f5beeada95a01c7b815508a82dcb0e1593682"
dependencies = [
"futures",
"futures-timer",
"rstest_macros",
"rustc_version",
]
[[package]]
name = "rstest_macros"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4165dfae59a39dd41d8dec720d3cbfbc71f69744efb480a3920f5d4e0cc6798d"
dependencies = [
"cfg-if",
"glob",
"proc-macro-crate",
"proc-macro2",
"quote",
"regex",
"relative-path",
"rustc_version",
"syn 2.0.106",
"unicode-ident",
]
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "ryu"
version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
[[package]]
name = "semver"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
[[package]]
name = "serde"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.219"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
[[package]]
name = "serde_json"
version = "1.0.143"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d401abef1d108fbd9cbaebc3e46611f4b1021f714a0597a71f41ee463f5f4a5a"
dependencies = [
"indexmap 2.14.0",
"itoa",
"memchr",
"ryu",
"serde",
]
[[package]]
name = "serde_type_name"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92acc4cd6ae20767c54d6cf1a075624e7f4d9e99d7ebc685398ff243144d8714"
dependencies = [
"serde",
]
[[package]]
name = "simcore"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a20dfec85e94e569fa5b810b2d992c68b361f31bfba471447427536488c9c292"
dependencies = [
"colored",
"downcast-rs",
"dyn-clone",
"erased-serde",
"futures",
"log",
"rand",
"rand_pcg",
"rustc-hash",
"serde",
"serde_json",
"serde_type_name",
]
[[package]]
name = "slab"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
[[package]]
name = "strsim"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
[[package]]
name = "sugars"
version = "3.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc0db74f9ee706e039d031a560bd7d110c7022f016051b3d33eeff9583e3e67a"
[[package]]
name = "syn"
version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "2.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "target-lexicon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]]
name = "termcolor"
version = "1.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
dependencies = [
"winapi-util",
]
[[package]]
name = "textwrap"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap 2.14.0",
"toml_datetime",
"winnow",
]
[[package]]
name = "typeid"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
[[package]]
name = "unicode-ident"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
dependencies = [
"winapi-i686-pc-windows-gnu",
"winapi-x86_64-pc-windows-gnu",
]
[[package]]
name = "winapi-i686-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.0",
]
[[package]]
name = "winapi-x86_64-pc-windows-gnu"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows-link"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
[[package]]
name = "windows-sys"
version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "winnow"
version = "0.7.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf"
dependencies = [
"memchr",
]
[[package]]
name = "zerocopy"
version = "0.8.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.106",
]
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "distsys-guarantees"
version = "0.1.0"
edition = "2021"
resolver = "3"
[dependencies]
anysystem = "=0.3.0"
indexmap = "2.12"
assertables = "3.2.2"
clap = { version = "3.1.17", features = ["cargo", "derive"] }
env_logger = "0.9.0"
log = "0.4.14"
pyo3 = { version = "=0.29.2", features = ["auto-initialize"] }
rand = "0.8.5"
rand_pcg = "0.3.1"
sugars = "3.0.0"
+19
View File
@@ -0,0 +1,19 @@
# syntax=docker/dockerfile:1
FROM rust:1.97.1-bookworm@sha256:0e2bcaef56d041a486784e54104a81aebe0da44bd03019bd70bc0401e42e4a97 AS builder
RUN apt-get update && apt-get install -y build-essential python3-dev
WORKDIR /tests
COPY . .
RUN --mount=type=cache,id=distsys-course-cargo-registry,target=/usr/local/cargo/registry,sharing=locked \
--mount=type=cache,id=distsys-course-cargo-target-rust-1-97-1,target=/tests/target,sharing=locked \
cargo install --locked --path . --root /opt/course
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y python3-dev && rm -rf /var/lib/apt/lists/*
COPY --from=builder /opt/course/bin/distsys-guarantees /usr/local/bin/distsys-guarantees
WORKDIR /solution
ENTRYPOINT ["timeout", "-k", "10", "300", "distsys-guarantees", "-i", "guarantees.py"]
@@ -0,0 +1,2 @@
max_width = 120
newline_style = "Unix"
File diff suppressed because it is too large Load Diff
+285
View File
@@ -0,0 +1,285 @@
use std::collections::HashMap;
use assertables::{assume, assume_eq};
use sugars::boxed;
use anysystem::python::PyProcessFactory;
use anysystem::test::TestResult;
use anysystem::{Message, System};
#[derive(Copy, Clone)]
pub struct TestConfig<'a> {
pub impl_path: &'a str,
pub sender_class: &'a str,
pub receiver_class: &'a str,
pub seed: u64,
pub monkeys: u32,
pub reliable: bool,
pub once: bool,
pub ordered: bool,
}
pub fn build_system(config: &TestConfig, measure_max_size: bool) -> System {
let mut sys = System::new(config.seed);
sys.add_node("sender-node");
sys.add_node("receiver-node");
let sender_f = PyProcessFactory::new(config.impl_path, config.sender_class);
let mut sender = sender_f.build(("sender", "receiver"), config.seed);
if measure_max_size {
sender.set_max_size_freq(100);
}
sys.add_process("sender", boxed!(sender), "sender-node");
let receiver_f = PyProcessFactory::new(config.impl_path, config.receiver_class);
let mut receiver = receiver_f.build(("receiver",), config.seed);
if measure_max_size {
receiver.set_max_size_freq(100);
}
sys.add_process("receiver", boxed!(receiver), "receiver-node");
sys
}
pub fn generate_message_texts(sys: &mut System, message_count: usize) -> Vec<String> {
if message_count == 5 {
["distributed", "systems", "need", "some", "guarantees"]
.map(String::from)
.to_vec()
} else {
let mut messages = Vec::new();
for _i in 0..message_count {
let msg = if message_count == 10 {
format!("{}C", sys.gen_range(20..30))
} else {
sys.random_string(100)
};
messages.push(msg);
}
messages
}
}
pub fn send_messages(sys: &mut System, message_count: usize) -> Vec<Message> {
let texts = generate_message_texts(sys, message_count);
let mut messages = Vec::new();
for text in texts {
let msg = Message::new("MESSAGE", &format!(r#"{{"text": "{text}"}}"#));
sys.send_local_message("sender", msg.clone());
if message_count <= 50 {
let steps = sys.gen_range(0..2);
if steps > 0 {
sys.steps(steps);
}
} else {
let duration = sys.gen_range(0.0..2.0);
sys.step_for_duration(duration);
};
messages.push(msg);
}
messages
}
pub fn check_delivered_messages(
delivered: &[Message],
expected_msg_count: &HashMap<String, i32>,
expected_tip: &String,
) -> Result<HashMap<String, i32>, String> {
assert!(!expected_msg_count.is_empty());
let mut delivered_msg_count = HashMap::default();
for msg in delivered.iter() {
// assuming all messages have the same type
assume_eq!(msg.tip, *expected_tip, format!("Wrong message type {}", msg.tip))?;
assume!(
expected_msg_count.contains_key(&msg.data),
format!("Wrong message data: {}", msg.data)
)?;
*delivered_msg_count.entry(msg.data.clone()).or_insert(0) += 1;
}
Ok(delivered_msg_count)
}
pub fn check_message_delivery_reliable(
delivered_msg_count: &HashMap<String, i32>,
expected_msg_count: &HashMap<String, i32>,
) -> TestResult {
for (data, expected_count) in expected_msg_count {
let delivered_count = delivered_msg_count.get(data).unwrap_or(&0);
assume!(
delivered_count >= expected_count,
format!(
"Message {} is not delivered (observed count {} < expected count {})",
data, delivered_count, expected_count
)
)?;
}
Ok(true)
}
pub fn check_message_delivery_once(
delivered_msg_count: &HashMap<String, i32>,
expected_msg_count: &HashMap<String, i32>,
) -> TestResult {
for (data, delivered_count) in delivered_msg_count {
if expected_msg_count.contains_key(data) {
let expected_count = expected_msg_count[data];
assume!(
*delivered_count <= expected_count,
format!(
"Message {} is delivered more than once (observed count {} > expected count {})",
data, delivered_count, expected_count
)
)?;
}
}
Ok(true)
}
pub fn check_message_delivery_ordered(delivered: &[Message], sent: &[Message]) -> TestResult {
let mut next_idx = 0;
for i in 0..delivered.len() {
let msg = &delivered[i];
let mut matched = false;
while !matched && next_idx < sent.len() {
if msg.data == sent[next_idx].data {
matched = true;
} else {
next_idx += 1;
}
}
assume!(
matched,
format!("Order violation: {} after {}", msg.data, &delivered[i - 1].data)
)?;
}
Ok(true)
}
pub fn check_guarantees(sys: &mut System, sent: &[Message], config: &TestConfig) -> TestResult {
let mut expected_msg_count = HashMap::new();
for msg in sent {
*expected_msg_count.entry(msg.data.clone()).or_insert(0) += 1;
}
let delivered = sys.read_local_messages("receiver");
// check that delivered messages have expected type and data
let delivered_msg_count = check_delivered_messages(&delivered, &expected_msg_count, &sent[0].tip)?;
// check delivered message count according to expected guarantees
if config.reliable {
check_message_delivery_reliable(&delivered_msg_count, &expected_msg_count)?;
}
if config.once {
check_message_delivery_once(&delivered_msg_count, &expected_msg_count)?;
}
if config.ordered {
check_message_delivery_ordered(&delivered, sent)?;
}
Ok(true)
}
#[allow(clippy::too_many_arguments)]
pub fn check_overhead(
guarantee: &str,
faulty: bool,
message_count: usize,
sender_mem: u64,
receiver_mem: u64,
net_message_count: u64,
net_traffic: u64,
throughput: f64,
) -> TestResult {
let (sender_mem_limit, receiver_mem_limit, net_message_count_limit, net_traffic_limit, throughput_limit) =
match guarantee {
"AMO" => match message_count {
100 => {
if !faulty {
(800, 1500, 100, 20000, 0.6)
} else {
(800, 3500, 100, 20000, 0.6)
}
}
1000 => {
if !faulty {
(800, 1500, 1000, 200000, 0.6)
} else {
(800, 30000, 1000, 200000, 0.6)
}
}
_ => (u64::MAX, u64::MAX, u64::MAX, u64::MAX, 0.),
},
"ALO" => match message_count {
100 => {
if !faulty {
(1700, 600, 200, 20000, 0.6)
} else {
(5000, 600, 500, 40000, 0.6)
}
}
1000 => {
if !faulty {
(2200, 600, 2000, 200000, 0.6)
} else {
(6000, 600, 5000, 400000, 0.6)
}
}
_ => (u64::MAX, u64::MAX, u64::MAX, u64::MAX, 0.),
},
"EO" => match message_count {
100 => {
if !faulty {
(1700, 1500, 200, 20000, 0.6)
} else {
(5000, 2200, 500, 40000, 0.6)
}
}
1000 => {
if !faulty {
(2200, 1500, 2000, 200000, 0.6)
} else {
(6000, 2200, 5000, 400000, 0.6)
}
}
_ => (u64::MAX, u64::MAX, u64::MAX, u64::MAX, 0.),
},
"EOO" => match message_count {
100 => {
if !faulty {
(2900, 1200, 200, 25000, 0.4)
} else {
(9000, 2500, 500, 45000, 0.4)
}
}
1000 => {
if !faulty {
(3400, 1200, 2000, 250000, 0.4)
} else {
(55000, 4000, 5000, 450000, 0.4)
}
}
_ => (u64::MAX, u64::MAX, u64::MAX, u64::MAX, 0.),
},
_ => (u64::MAX, u64::MAX, u64::MAX, u64::MAX, 0.),
};
assume!(
sender_mem <= sender_mem_limit,
format!("Sender memory > {}", sender_mem_limit)
)?;
assume!(
receiver_mem <= receiver_mem_limit,
format!("Receiver memory > {}", receiver_mem_limit)
)?;
assume!(
net_message_count <= net_message_count_limit,
format!("Message count > {}", net_message_count_limit)
)?;
assume!(
net_traffic <= net_traffic_limit,
format!("Traffic > {}", net_traffic_limit)
)?;
assume!(
throughput >= throughput_limit,
format!("Throughput < {}", throughput_limit)
)?;
Ok(true)
}
+310
View File
@@ -0,0 +1,310 @@
mod common;
mod shared_state;
mod tests;
mod tests_mc;
use indexmap::IndexMap;
use std::collections::HashSet;
use std::env;
use std::io::Write;
use clap::Parser;
use env_logger::Builder;
use log::LevelFilter;
use anysystem::test::{TestResult, TestSuite};
use crate::common::TestConfig;
use crate::tests::*;
use crate::tests_mc::*;
/// Guarantees Homework Tests
#[derive(Parser, Debug)]
#[clap(about, long_about = None)]
struct Args {
/// Path to Python file with solution
#[clap(long = "impl", short = 'i', default_value = "solution/guarantees.py")]
solution_path: String,
/// Test to run (optional)
#[clap(long = "test", short)]
test: Option<String>,
/// Print execution trace
#[clap(long, short)]
debug: bool,
/// Guarantee to check
#[clap(long, short, possible_values = ["AMO", "ALO", "EO", "EOO"])]
guarantee: Option<String>,
/// Random seed used in tests
#[clap(long, short, default_value = "123")]
seed: u64,
/// Number of chaos monkey runs
#[clap(long, short, default_value = "0")]
monkeys: u32,
/// Run overhead tests
#[clap(long, short)]
overhead: bool,
/// Run model checking tests
#[clap(long, short = 'c')]
model_checking: bool,
}
fn main() {
let args = Args::parse();
if !shared_state::validate_or_report(
&args.solution_path,
&[
"AtMostOnceSender",
"AtMostOnceReceiver",
"AtLeastOnceSender",
"AtLeastOnceReceiver",
"ExactlyOnceSender",
"ExactlyOnceReceiver",
"ExactlyOnceOrderedSender",
"ExactlyOnceOrderedReceiver",
],
) {
return;
}
if args.debug {
Builder::new()
.filter(Some("anysystem"), LevelFilter::Debug)
.format(|buf, record| writeln!(buf, "{}", record.args()))
.init();
}
let guarantee = args.guarantee.as_deref();
env::set_var("PYTHONHASHSEED", args.seed.to_string());
let mut config = TestConfig {
impl_path: &args.solution_path,
sender_class: "",
receiver_class: "",
seed: args.seed,
monkeys: args.monkeys,
reliable: false,
once: false,
ordered: false,
};
let mut tests = TestSuite::new();
// At most once
if guarantee.is_none() || guarantee == Some("AMO") {
config.sender_class = "AtMostOnceSender";
config.receiver_class = "AtMostOnceReceiver";
config.once = true;
// without drops should be reliable
config.reliable = true;
tests.add("[AT MOST ONCE] NORMAL", test_normal, config);
tests.add("[AT MOST ONCE] NORMAL NON-UNIQUE", test_normal_non_unique, config);
tests.add("[AT MOST ONCE] DELAYED", test_delayed, config);
tests.add("[AT MOST ONCE] DUPLICATED", test_duplicated, config);
tests.add("[AT MOST ONCE] DELAYED+DUPLICATED", test_delayed_duplicated, config);
tests.add("[AT MOST ONCE] OLD DUPLICATE", test_old_duplicate, config);
// with drops is not reliable
config.reliable = false;
tests.add("[AT MOST ONCE] DROPPED", test_dropped, config);
if args.monkeys > 0 {
tests.add("[AT MOST ONCE] CHAOS MONKEY", test_chaos_monkey, config);
}
if args.overhead {
config.reliable = true;
tests.add(
"[AT MOST ONCE] OVERHEAD NORMAL",
|x| test_overhead(x, "AMO", false),
config,
);
config.reliable = false;
tests.add(
"[AT MOST ONCE] OVERHEAD FAULTY",
|x| test_overhead(x, "AMO", true),
config,
);
}
if args.model_checking {
tests.add("[AT MOST ONCE] MODEL CHECKING", test_mc_reliable_network, config);
tests.add(
"[AT MOST ONCE] MODEL CHECKING MESSAGE DROPS",
test_mc_message_drops,
config,
);
tests.add(
"[AT MOST ONCE] MODEL CHECKING UNSTABLE NETWORK",
test_mc_unstable_network,
config,
);
}
}
// At least once
if guarantee.is_none() || guarantee == Some("ALO") {
config.sender_class = "AtLeastOnceSender";
config.receiver_class = "AtLeastOnceReceiver";
config.reliable = true;
config.once = false;
tests.add("[AT LEAST ONCE] NORMAL", test_normal, config);
tests.add("[AT LEAST ONCE] NORMAL NON-UNIQUE", test_normal_non_unique, config);
tests.add("[AT LEAST ONCE] DELAYED", test_delayed, config);
tests.add("[AT LEAST ONCE] DUPLICATED", test_duplicated, config);
tests.add("[AT LEAST ONCE] DELAYED+DUPLICATED", test_delayed_duplicated, config);
tests.add("[AT LEAST ONCE] DROPPED", test_dropped, config);
if args.monkeys > 0 {
tests.add("[AT LEAST ONCE] CHAOS MONKEY", test_chaos_monkey, config);
}
if args.overhead {
tests.add(
"[AT LEAST ONCE] OVERHEAD NORMAL",
|x| test_overhead(x, "ALO", false),
config,
);
tests.add(
"[AT LEAST ONCE] OVERHEAD FAULTY",
|x| test_overhead(x, "ALO", true),
config,
);
}
if args.model_checking {
tests.add("[AT LEAST ONCE] MODEL CHECKING", test_mc_reliable_network, config);
tests.add(
"[AT LEAST ONCE] MODEL CHECKING MESSAGE DROPS",
test_mc_message_drops,
config,
);
tests.add(
"[AT LEAST ONCE] MODEL CHECKING UNSTABLE NETWORK",
test_mc_unstable_network,
config,
);
}
}
// Exactly once
if guarantee.is_none() || guarantee == Some("EO") {
config.sender_class = "ExactlyOnceSender";
config.receiver_class = "ExactlyOnceReceiver";
config.reliable = true;
config.once = true;
tests.add("[EXACTLY ONCE] NORMAL", test_normal, config);
tests.add("[EXACTLY ONCE] NORMAL NON-UNIQUE", test_normal_non_unique, config);
tests.add("[EXACTLY ONCE] DELAYED", test_delayed, config);
tests.add("[EXACTLY ONCE] DUPLICATED", test_duplicated, config);
tests.add("[EXACTLY ONCE] DELAYED+DUPLICATED", test_delayed_duplicated, config);
tests.add("[EXACTLY ONCE] DROPPED", test_dropped, config);
if args.monkeys > 0 {
tests.add("[EXACTLY ONCE] CHAOS MONKEY", test_chaos_monkey, config);
}
if args.overhead {
tests.add(
"[EXACTLY ONCE] OVERHEAD NORMAL",
|x| test_overhead(x, "EO", false),
config,
);
tests.add(
"[EXACTLY ONCE] OVERHEAD FAULTY",
|x| test_overhead(x, "EO", true),
config,
);
}
if args.model_checking {
tests.add("[EXACTLY ONCE] MODEL CHECKING", test_mc_reliable_network, config);
tests.add(
"[EXACTLY ONCE] MODEL CHECKING MESSAGE DROPS",
test_mc_message_drops,
config,
);
tests.add(
"[EXACTLY ONCE] MODEL CHECKING UNSTABLE NETWORK",
test_mc_unstable_network,
config,
);
}
}
// EXACTLY ONCE ORDERED
if guarantee.is_none() || guarantee == Some("EOO") {
config.sender_class = "ExactlyOnceOrderedSender";
config.receiver_class = "ExactlyOnceOrderedReceiver";
config.reliable = true;
config.once = true;
config.ordered = true;
tests.add("[EXACTLY ONCE ORDERED] NORMAL", test_normal, config);
tests.add(
"[EXACTLY ONCE ORDERED] NORMAL NON-UNIQUE",
test_normal_non_unique,
config,
);
tests.add("[EXACTLY ONCE ORDERED] DELAYED", test_delayed, config);
tests.add("[EXACTLY ONCE ORDERED] DUPLICATED", test_duplicated, config);
tests.add(
"[EXACTLY ONCE ORDERED] DELAYED+DUPLICATED",
test_delayed_duplicated,
config,
);
tests.add("[EXACTLY ONCE ORDERED] DROPPED", test_dropped, config);
if args.monkeys > 0 {
tests.add("[EXACTLY ONCE ORDERED] CHAOS MONKEY", test_chaos_monkey, config);
}
if args.overhead {
tests.add(
"[EXACTLY ONCE ORDERED] OVERHEAD NORMAL",
|x| test_overhead(x, "EOO", false),
config,
);
tests.add(
"[EXACTLY ONCE ORDERED] OVERHEAD FAULTY",
|x| test_overhead(x, "EOO", true),
config,
);
}
if args.model_checking {
tests.add(
"[EXACTLY ONCE ORDERED] MODEL CHECKING",
test_mc_reliable_network,
config,
);
tests.add(
"[EXACTLY ONCE ORDERED] MODEL CHECKING MESSAGE DROPS",
test_mc_message_drops,
config,
);
tests.add(
"[EXACTLY ONCE ORDERED] MODEL CHECKING UNSTABLE NETWORK",
test_mc_unstable_network,
config,
);
}
}
if let Some(test) = &args.test {
tests.run_test(test);
} else {
let (_, results) = tests.run();
let score = score(results);
println!("SCORE: {score}\n");
}
}
fn score(results: IndexMap<String, TestResult>) -> f32 {
let guarantees = HashSet::from(["AT MOST ONCE", "AT LEAST ONCE", "EXACTLY ONCE", "EXACTLY ONCE ORDERED"]);
let mut failed_guarantees: HashSet<&str> = HashSet::new();
let mut failed_overheads: HashSet<&str> = HashSet::new();
for (test, result) in results {
if result.is_err() {
for guarantee in guarantees.iter() {
if test.contains(format!("[{guarantee}]").as_str()) {
if test.contains("OVERHEAD") {
failed_overheads.insert(guarantee);
} else {
failed_guarantees.insert(guarantee);
}
}
}
}
}
9. - failed_guarantees.len() as f32 * 2. - f32::from(!failed_overheads.is_empty())
}
@@ -0,0 +1,636 @@
use std::ffi::CString;
use pyo3::prelude::*;
use pyo3::types::{PyList, PyModule};
const VALIDATOR_CODE: &str = include_str!("../shared_state_validator.py");
fn validator_module<'py>(py: Python<'py>) -> PyResult<Bound<'py, PyModule>> {
let code = CString::new(VALIDATOR_CODE).expect("validator source contains a null byte");
PyModule::from_code(py, &code, c"shared_state_validator.py", c"shared_state_validator")
}
#[cfg(test)]
fn validate_source(source: &str, filename: &str, class_names: &[&str]) -> Result<Vec<String>, String> {
Python::attach(|py| {
let module = validator_module(py).map_err(|error| error.to_string())?;
let class_names = PyList::new(py, class_names).map_err(|error| error.to_string())?;
module
.getattr("validate_source")
.and_then(|function| function.call1((source, filename, class_names)))
.and_then(|result| result.extract::<Vec<String>>())
.map_err(|error| error.to_string())
})
}
fn validate_solution(path: &str, class_names: &[&str]) -> Result<Vec<String>, String> {
Python::attach(|py| {
let module = validator_module(py).map_err(|error| error.to_string())?;
let class_names = PyList::new(py, class_names).map_err(|error| error.to_string())?;
module
.getattr("validate_solution")
.and_then(|function| function.call1((path, class_names)))
.and_then(|result| result.extract::<Vec<String>>())
.map_err(|error| error.to_string())
})
}
pub fn validate_or_report(path: &str, class_names: &[&str]) -> bool {
match validate_solution(path, class_names) {
Ok(violations) if violations.is_empty() => true,
Ok(violations) => {
println!("ERROR: shared state between AnySystem processes is forbidden");
for violation in violations {
println!("{violation}");
}
println!("\nSCORE: 0\n");
false
}
Err(error) => panic!("failed to validate solution for shared state: {error}"),
}
}
#[cfg(test)]
mod tests {
use super::{validate_solution, validate_source};
const PROCESS: &[&str] = &["ProcessImpl"];
fn violations(source: &str) -> Vec<String> {
validate_source(source, "solution.py", PROCESS).unwrap()
}
#[test]
fn accepts_imports_helpers_constants_and_instance_state() {
let source = r#"
import random
from dataclasses import dataclass, field
from enum import Enum, auto
from types import MappingProxyType
LIMIT = 2 ** 10
NAMES = ("a", "b")
LOOKUP = MappingProxyType({"a": 1, "b": 2})
@dataclass
class Helper:
values: list[str] = field(default_factory=list)
@dataclass(frozen=True)
class Status:
ok: bool
class Kind(Enum):
READY = auto()
class ProcessImpl:
RETRIES = 3
READY = Status(True)
def __init__(self):
self.values = []
self.helper = Helper()
def choose(self):
return random.choice(NAMES)
"#;
assert_eq!(violations(source), Vec::<String>::new());
}
#[test]
fn checks_local_imports_without_executing_them() {
let suffix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let directory = std::env::temp_dir().join(format!(
"distsys-shared-state-validator-{}-{suffix}",
std::process::id()
));
std::fs::create_dir_all(&directory).unwrap();
let entrypoint = directory.join("solution.py");
std::fs::write(&entrypoint, "import helper\n\nclass ProcessImpl:\n pass\n").unwrap();
std::fs::write(
directory.join("helper.py"),
"class Shared:\n data = {}\n\nraise RuntimeError('must not execute')\n",
)
.unwrap();
let result = validate_solution(entrypoint.to_str().unwrap(), PROCESS);
std::fs::remove_dir_all(directory).unwrap();
let violations = result.unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].contains("Shared.data"));
}
#[test]
fn rejects_shared_state_hidden_behind_helper_class() {
let source = r#"
from enum import Enum
class Shared:
data = {}
@classmethod
def data_ref(cls):
return cls.data
class UnsafeKind(Enum):
VALUES = []
class ProcessImpl:
def write(self):
Shared.data_ref()["key"] = "value"
"#;
let violations = violations(source);
assert_eq!(violations.len(), 2);
assert!(violations.iter().any(|item| item.contains("Shared.data")));
assert!(violations.iter().any(|item| item.contains("UnsafeKind.VALUES")));
}
#[test]
fn rejects_module_and_process_class_state() {
let source = r#"
CACHE = {}
from dataclasses import dataclass, field
@dataclass(frozen=True)
class FrozenButMutable:
values: list[int] = field(default_factory=list)
BROKEN = FrozenButMutable()
class Base:
pending = set()
class ProcessImpl(Base):
values = []
"#;
let violations = violations(source);
assert_eq!(violations.len(), 4);
assert!(violations.iter().any(|message| message.contains("CACHE")));
assert!(violations.iter().any(|message| message.contains("BROKEN")));
assert!(violations.iter().any(|message| message.contains("Base.pending")));
assert!(violations.iter().any(|message| message.contains("ProcessImpl.values")));
}
#[test]
fn rejects_module_level_dynamic_shared_state() {
let source = r#"
def helper():
pass
class ProcessImpl:
COUNT = 0
alias = ProcessImpl
alias.cache = {}
helper.cache = []
setattr(ProcessImpl, "pending", set())
"#;
let violations = violations(source);
assert_eq!(violations.len(), 3);
assert!(violations
.iter()
.all(|message| message.contains("module-level shared state")));
}
#[test]
fn rejects_mutable_defaults_and_scope_state() {
let source = r#"
class ProcessImpl:
def on_message(self, message, cache={}):
global counter
def next_value():
nonlocal message
return message
"#;
let violations = violations(source);
assert_eq!(violations.len(), 2);
}
#[test]
fn rejects_shared_but_accepts_local_closure_state() {
let source = r#"
def make_counter():
count = 0
def next_value():
nonlocal count
count += 1
return count
return next_value
SHARED_COUNTER = make_counter()
class ProcessImpl:
def local_counter(self):
count = 0
def next_value():
nonlocal count
count += 1
return count
return next_value()
"#;
let violations = violations(source);
assert_eq!(violations.len(), 2);
assert!(violations.iter().any(|message| message.contains("shared closure")));
}
#[test]
fn rejects_getattr_eval_and_shared_cache_decorator() {
let source = r#"
from functools import lru_cache
@lru_cache
def cached_value(key):
return key
class ProcessImpl:
cache = ()
def on_message(self, message):
getattr(type(self), "cache").clear()
eval("globals()")
"#;
let violations = violations(source);
assert_eq!(violations.len(), 3);
assert!(violations.iter().any(|message| message.contains("lru_cache")));
assert!(violations.iter().any(|message| message.contains("clear")));
assert!(violations.iter().any(|message| message.contains("eval")));
}
#[test]
fn rejects_direct_aliased_and_reflective_mutation() {
let source = r#"
class ProcessImpl:
COUNT = 0
def on_message(self, message):
cls = type(self)
state = cls.__dict__
cls.COUNT += 1
state.update({"x": 1})
setattr(cls, "other", [])
globals()["hidden"] = {}
exec("hidden = {}")
"#;
let violations = violations(source);
assert!(violations.len() >= 5);
assert!(violations.iter().any(|message| message.contains("assignment mutates")));
assert!(violations.iter().any(|message| message.contains("setattr")));
assert!(violations.iter().any(|message| message.contains("globals")));
assert!(violations.iter().any(|message| message.contains("exec")));
}
#[test]
fn rejects_spoofed_safe_names_and_aliased_cache_decorator() {
let source = r#"
def tuple():
return []
def MappingProxyType(value):
return value
def dataclass(*args, **kwargs):
def wrap(cls):
return cls
return wrap
SHARED_TUPLE = tuple()
SHARED_MAPPING = MappingProxyType({})
@dataclass(frozen=True)
class Mutable:
pass
SHARED_OBJECT = Mutable()
from functools import lru_cache as memo
@memo
def cached(value):
return value
class ProcessImpl:
pass
"#;
let violations = violations(source);
assert!(violations.iter().any(|message| message.contains("SHARED_TUPLE")));
assert!(violations.iter().any(|message| message.contains("SHARED_MAPPING")));
assert!(violations.iter().any(|message| message.contains("SHARED_OBJECT")));
assert!(violations.iter().any(|message| message.contains("lru_cache")));
}
#[test]
fn rejects_imported_and_function_object_state() {
let source = r#"
import math
import sys
SHARED = sys.modules
holder = lambda: None
module_alias = math
math.shared = {}
holder.shared = {}
module_alias.other = {}
class ProcessImpl:
def __init__(self):
self.shared = sys.modules
def write(self):
local_alias = math
local_alias.more = {}
self.shared["hidden"] = {}
"#;
let violations = violations(source);
assert!(violations.iter().any(|message| message.contains("SHARED")));
assert!(violations.len() >= 6);
}
#[test]
fn rejects_unknown_constructor_as_mutable_default() {
let source = r#"
class Box:
def __init__(self):
self.values = {}
class ProcessImpl:
def on_message(self, message, box=Box()):
pass
"#;
let violations = violations(source);
assert!(violations
.iter()
.any(|message| message.contains("mutable default argument")));
}
#[test]
fn rejects_dunder_and_class_namespace_reflection() {
let source = r#"
from dataclasses import dataclass
@dataclass(frozen=True)
class Frozen:
value: int
SHARED = Frozen(1)
class ProcessImpl:
locals()["shared"] = {}
def on_message(self, message):
type.__setattr__(ProcessImpl, "cache", {})
object.__setattr__(SHARED, "value", 2)
"#;
let violations = violations(source);
assert!(violations
.iter()
.any(|message| message.contains("class-level shared state")));
assert!(violations.iter().any(|message| message.contains("__setattr__")));
assert!(violations.len() >= 3);
}
#[test]
fn rejects_mutating_frozen_dataclass_methods() {
let source = r#"
from dataclasses import dataclass
@dataclass(frozen=True)
class Frozen:
value: int = 1
def __post_init__(self):
object.__setattr__(self, "state", {})
def put(self, key, value):
self.state[key] = value
GLOBAL = Frozen()
class ProcessImpl:
def write(self):
GLOBAL.put("x", 1)
"#;
let violations = violations(source);
assert!(violations.iter().any(|message| message.contains("GLOBAL")));
}
#[test]
fn rejects_dunder_and_operator_item_mutation() {
let source = r#"
import math
import operator
class ProcessImpl:
def write(self):
math.__dict__.__setitem__("shared", {})
operator.setitem(math.__dict__, "other", {})
"#;
let violations = violations(source);
assert!(violations.len() >= 2);
assert!(violations.iter().any(|message| message.contains("__setitem__")));
assert!(violations.iter().any(|message| message.contains("operator")));
}
#[test]
fn rejects_class_local_and_star_cache_decorators() {
let source = r#"
from functools import *
@lru_cache
def module_cached():
return []
class ProcessImpl:
from functools import lru_cache as memo
@staticmethod
@memo
def shared():
return []
"#;
let violations = violations(source);
assert_eq!(
violations
.iter()
.filter(|message| message.contains("lru_cache"))
.count(),
2
);
}
#[test]
fn checks_literal_dynamic_local_imports() {
let suffix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let directory = std::env::temp_dir().join(format!(
"distsys-dynamic-import-validator-{}-{suffix}",
std::process::id()
));
std::fs::create_dir_all(&directory).unwrap();
let entrypoint = directory.join("solution.py");
std::fs::write(
&entrypoint,
"from importlib import import_module as load\n\nPREFIX = 'hel'\nMODULE = PREFIX + 'per'\n\nclass ProcessImpl:\n def __init__(self):\n self.shared = load(MODULE).CACHE\n",
)
.unwrap();
std::fs::write(directory.join("helper.py"), "CACHE = {}\n").unwrap();
let result = validate_solution(entrypoint.to_str().unwrap(), PROCESS);
std::fs::remove_dir_all(directory).unwrap();
let violations = result.unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].contains("CACHE"));
}
#[test]
fn rejects_shared_values_returned_by_wrappers_and_wrapped_cache() {
let source = r#"
import sys
from functools import lru_cache
def get_shared():
return sys.modules
def memo(function):
return lru_cache(function)
@memo
def shared():
return []
class ProcessImpl:
def __init__(self):
self.shared = get_shared()
def write(self):
self.shared["key"] = shared()
"#;
let violations = violations(source);
assert!(violations.iter().any(|message| message.contains("assignment mutates")));
assert!(violations.iter().any(|message| message.contains("memo")));
}
#[test]
fn rejects_aliased_mutator_callables() {
let source = r#"
import math
import operator
class ProcessImpl:
def write(self):
mutate = operator.setitem
mutate(math.__dict__, "first", {})
bound_mutate = math.__dict__.__setitem__
bound_mutate("second", {})
"#;
let violations = violations(source);
assert_eq!(violations.len(), 2);
assert!(violations.iter().any(|message| message.contains("operator")));
assert!(violations.iter().any(|message| message.contains("aliased")));
}
#[test]
fn rejects_unresolved_dynamic_local_imports() {
let suffix = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let directory = std::env::temp_dir().join(format!(
"distsys-unresolved-import-validator-{}-{suffix}",
std::process::id()
));
std::fs::create_dir_all(&directory).unwrap();
let entrypoint = directory.join("solution.py");
std::fs::write(
&entrypoint,
"from importlib import import_module as load\n\nclass ProcessImpl:\n def write(self):\n name = ''.join(['helper'])\n state = load(name).CACHE\n state['key'] = 1\n",
)
.unwrap();
std::fs::write(directory.join("helper.py"), "CACHE = {}\n").unwrap();
let result = validate_solution(entrypoint.to_str().unwrap(), PROCESS);
std::fs::remove_dir_all(directory).unwrap();
let violations = result.unwrap();
assert_eq!(violations.len(), 1);
assert!(violations[0].contains("cannot be resolved statically"));
}
#[test]
fn rejects_stateful_imported_api_but_allows_reviewed_calls() {
let source = r#"
import random
from random import seed as direct_seed
random.seed(1)
class ProcessImpl:
def write(self, value):
direct_seed(value)
reseed = random.seed
reseed(value)
def read(self):
return random.getstate()
def choose(self):
return random.choice((1, 2))
"#;
let violations = violations(source);
assert_eq!(violations.len(), 4);
assert!(violations.iter().any(|message| message.contains("random.seed")));
assert!(violations.iter().any(|message| message.contains("random.getstate")));
assert!(!violations.iter().any(|message| message.contains("random.choice")));
}
#[test]
fn rejects_passthrough_and_lambda_shared_references() {
let source = r#"
import sys
def identity(value):
return value
get_shared = lambda: sys.modules
lambda_identity = lambda value: value
class ProcessImpl:
def __init__(self):
self.first = identity(sys.modules)
self.second = get_shared()
self.third = lambda_identity(sys.modules)
def write(self):
self.first["first"] = {}
self.second["second"] = {}
self.third["third"] = {}
"#;
let violations = violations(source);
assert_eq!(violations.len(), 3);
assert!(violations.iter().all(|message| message.contains("assignment mutates")));
}
#[test]
fn rejects_getattr_hidden_imported_api_and_mutator() {
let source = r#"
import math
import random
class ProcessImpl:
def write(self, value):
getattr(random, "seed")(value)
getattr(math.__dict__, "__setitem__")("shared", {})
"#;
let violations = violations(source);
assert_eq!(violations.len(), 2);
assert!(violations.iter().any(|message| message.contains("random.seed")));
assert!(violations.iter().any(|message| message.contains("__setitem__")));
}
}
+158
View File
@@ -0,0 +1,158 @@
use std::fs;
use assertables::assume;
use rand::prelude::*;
use rand_pcg::Pcg64;
use anysystem::test::TestResult;
use anysystem::Message;
use crate::common::{build_system, check_guarantees, check_overhead, send_messages, TestConfig};
pub fn test_normal(config: &TestConfig) -> TestResult {
let mut sys = build_system(config, false);
let messages = send_messages(&mut sys, 5);
sys.step_until_no_events();
check_guarantees(&mut sys, &messages, config)?;
// We expect no more than 5 messages from sender in normal network conditions
let sent_count = sys.sent_message_count("sender");
assume!(
sent_count <= 5,
format!("Sender sent {} messages, expected at most 5", sent_count)
)
}
pub fn test_normal_non_unique(config: &TestConfig) -> TestResult {
let mut sys = build_system(config, false);
let messages = send_messages(&mut sys, 10);
sys.step_until_no_events();
check_guarantees(&mut sys, &messages, config)?;
// We expect no more than 10 messages from sender in normal network conditions (stable delay, no loss).
// If solution sends multiple messages without or with too small (<RTT) delay, this results in extra redundant
// traffic. We want to avoid wasting network resources, assuming that normal conditions happen most of the time.
let sent_count = sys.sent_message_count("sender");
assume!(
sent_count <= 10,
format!("Sender sent {} messages, expected at most 10", sent_count)
)
}
pub fn test_delayed(config: &TestConfig) -> TestResult {
let mut sys = build_system(config, false);
sys.network().set_delays(1., 3.);
let messages = send_messages(&mut sys, 5);
sys.step_until_no_events();
check_guarantees(&mut sys, &messages, config)
}
pub fn test_duplicated(config: &TestConfig) -> TestResult {
let mut sys = build_system(config, false);
sys.network().set_dupl_rate(0.3);
let messages = send_messages(&mut sys, 5);
sys.step_until_no_events();
check_guarantees(&mut sys, &messages, config)
}
pub fn test_delayed_duplicated(config: &TestConfig) -> TestResult {
let mut sys = build_system(config, false);
sys.network().set_delays(1., 3.);
sys.network().set_dupl_rate(0.3);
let messages = send_messages(&mut sys, 5);
sys.step_until_no_events();
check_guarantees(&mut sys, &messages, config)
}
pub fn test_old_duplicate(config: &TestConfig) -> TestResult {
let mut sys = build_system(config, false);
sys.network().set_delays(1., 3.);
sys.network().set_dupl_rate(1.);
let first = Message::new("MESSAGE", r#"{"text": "first"}"#);
let mut messages = vec![first.clone()];
sys.send_local_message("sender", first);
// Stop after the first copy is delivered.
while sys.local_outbox("receiver").is_empty() {
if !sys.step() {
return Err("The first message was not delivered".to_string());
}
}
// Keep the old duplicate pending while newer messages are delivered first.
// Zero-delay messages are processed before the delayed copy.
sys.network().set_dupl_rate(0.);
sys.network().set_delay(0.);
for i in 0..50 {
let msg = Message::new("MESSAGE", &format!(r#"{{"text": "message-{i}"}}"#));
sys.send_local_message("sender", msg.clone());
messages.push(msg);
}
sys.step_until_no_events();
check_guarantees(&mut sys, &messages, config)
}
pub fn test_dropped(config: &TestConfig) -> TestResult {
let mut sys = build_system(config, false);
sys.network().set_drop_rate(0.3);
let messages = send_messages(&mut sys, 5);
sys.step_until_no_events();
check_guarantees(&mut sys, &messages, config)
}
pub fn test_chaos_monkey(config: &TestConfig) -> TestResult {
let mut rand = Pcg64::seed_from_u64(config.seed);
for i in 1..=config.monkeys {
let mut run_config = *config;
run_config.seed = rand.next_u64();
println!("Run {} (seed: {})", i, run_config.seed);
let mut sys = build_system(&run_config, false);
sys.network().set_delays(1., 3.);
sys.network().set_dupl_rate(0.3);
sys.network().set_drop_rate(0.3);
let messages = send_messages(&mut sys, 50);
sys.step_until_no_events();
let res = check_guarantees(&mut sys, &messages, &run_config);
res.as_ref()?;
}
Ok(true)
}
pub fn test_overhead(config: &TestConfig, guarantee: &str, faulty: bool) -> TestResult {
for message_count in [100, 500, 1000] {
let mut sys = build_system(config, true);
if faulty {
sys.network().set_delays(1., 3.);
sys.network().set_dupl_rate(0.3);
sys.network().set_drop_rate(0.3);
}
let messages = send_messages(&mut sys, message_count);
sys.step_until_no_events();
let res = check_guarantees(&mut sys, &messages, config);
res.as_ref()?;
let sender_mem = sys.max_size("sender");
let receiver_mem = sys.max_size("receiver");
let net_message_count = sys.network().network_message_count();
let net_traffic = sys.network().traffic();
let throughput = message_count as f64 / sys.time();
println!(
"{message_count:<6} Send Mem: {sender_mem:<8} Recv Mem: {receiver_mem:<8} Messages: {net_message_count:<8} Traffic: {net_traffic:<8} Throughput: {throughput:.3}"
);
check_overhead(
guarantee,
faulty,
message_count,
sender_mem,
receiver_mem,
net_message_count,
net_traffic,
throughput,
)?;
}
let impl_code = fs::read_to_string(config.impl_path).unwrap();
assume!(
!impl_code.contains("<<") && !impl_code.contains(">>"),
"Implementation contains bitwise shift operators"
)?;
Ok(true)
}
@@ -0,0 +1,148 @@
use std::collections::HashMap;
use std::time::Duration;
use sugars::boxed;
use anysystem::logger::LogEntry;
use anysystem::mc::{
predicates::{goals, invariants, prunes},
strategies::Bfs,
InvariantFn, ModelChecker, StrategyConfig,
};
use anysystem::test::TestResult;
use anysystem::Message;
use crate::common::{
build_system, check_delivered_messages, check_message_delivery_once, check_message_delivery_ordered,
check_message_delivery_reliable, generate_message_texts, TestConfig,
};
fn mc_invariant_guarantees(messages_expected: Vec<Message>, config: TestConfig) -> InvariantFn {
boxed!(move |state| {
let mut expected_msg_count = HashMap::new();
for msg in &messages_expected {
*expected_msg_count.entry(msg.data.clone()).or_insert(0) += 1;
}
let delivered = &state.node_states["receiver-node"].proc_states["receiver"].local_outbox;
// check that delivered messages have expected type and data
let delivered_msg_count = check_delivered_messages(delivered, &expected_msg_count, &messages_expected[0].tip)?;
// check delivered message count according to expected guarantees
if config.reliable && state.events.is_empty() {
check_message_delivery_reliable(&delivered_msg_count, &expected_msg_count)?;
}
if config.once {
check_message_delivery_once(&delivered_msg_count, &expected_msg_count)?;
}
if config.ordered {
check_message_delivery_ordered(delivered, &messages_expected)?;
}
Ok(())
})
}
pub fn test_mc_reliable_network(config: &TestConfig) -> TestResult {
let mut sys = build_system(config, false);
let messages: Vec<Message> = generate_message_texts(&mut sys, 2)
.into_iter()
.map(|text| Message::new("MESSAGE", &format!(r#"{{"text": "{text}"}}"#)))
.collect();
let strategy_config = StrategyConfig::default()
.prune(prunes::sent_messages_limit(4))
.goal(goals::got_n_local_messages("receiver-node", "receiver", 2))
.invariant(invariants::all_invariants(vec![
invariants::state_depth(20),
mc_invariant_guarantees(messages.clone(), *config),
]));
let mut mc = ModelChecker::new(&sys);
let res = mc.run_with_change::<Bfs>(strategy_config, move |sys| {
for message in messages {
sys.send_local_message("sender-node", "sender", message);
}
});
if let Err(e) = res {
e.print_trace();
Err(e.message())
} else {
Ok(true)
}
}
pub fn test_mc_message_drops(config: &TestConfig) -> TestResult {
let mut sys = build_system(config, false);
sys.network().set_drop_rate(0.1);
let messages: Vec<Message> = generate_message_texts(&mut sys, 2)
.into_iter()
.map(|text| Message::new("MESSAGE", &format!(r#"{{"text": "{text}"}}"#)))
.collect();
let strategy_config = StrategyConfig::default()
.prune(prunes::state_depth(7))
.goal(goals::any_goal(vec![
goals::got_n_local_messages("receiver-node", "receiver", 2),
goals::no_events(),
]))
.invariant(mc_invariant_guarantees(messages.clone(), *config));
let mut mc = ModelChecker::new(&sys);
let res = mc.run_with_change::<Bfs>(strategy_config, move |sys| {
for message in messages {
sys.send_local_message("sender-node", "sender", message);
}
});
if let Err(e) = res {
e.print_trace();
Err(e.message())
} else {
Ok(true)
}
}
pub fn test_mc_unstable_network(config: &TestConfig) -> TestResult {
let mut sys = build_system(config, false);
sys.network().set_drop_rate(0.1);
sys.network().set_dupl_rate(0.1);
let msg_count = if config.ordered { 3 } else { 2 };
let messages: Vec<Message> = generate_message_texts(&mut sys, msg_count)
.into_iter()
.map(|text| Message::new("MESSAGE", &format!(r#"{{"text": "{text}"}}"#)))
.collect();
let num_drops_allowed = 1;
let num_duplication_allowed = 1;
let goal = if config.reliable && config.once {
goals::all_goals(vec![
goals::got_n_local_messages("receiver-node", "receiver", msg_count),
goals::no_events(),
])
} else {
goals::no_events()
};
let mut invariants = vec![
invariants::state_depth(20),
mc_invariant_guarantees(messages.clone(), *config),
];
if config.ordered {
invariants.push(invariants::time_limit(Duration::from_secs(80)))
};
let strategy_config = StrategyConfig::default()
.prune(prunes::any_prune(vec![
prunes::events_limit(LogEntry::is_mc_message_dropped, num_drops_allowed),
prunes::events_limit(LogEntry::is_mc_message_duplicated, num_duplication_allowed),
prunes::events_limit(LogEntry::is_mc_timer_fired, 1),
prunes::events_limit(LogEntry::is_mc_message_received, msg_count + num_drops_allowed),
]))
.goal(goal)
.invariant(invariants::all_invariants(invariants));
let mut mc = ModelChecker::new(&sys);
let res = mc.run_with_change::<Bfs>(strategy_config, |sys| {
for msg in messages {
sys.send_local_message("sender-node", "sender", msg.clone());
}
});
if let Err(e) = res {
e.print_trace();
Err(e.message())
} else {
Ok(true)
}
}
+750
View File
@@ -0,0 +1,750 @@
"""Portable student CLI for registration and solution submission."""
from __future__ import annotations
import argparse
import getpass
import os
import queue
import re
import stat
import subprocess
import sys
import tempfile
import threading
import unicodedata
import uuid
import zipfile
from collections.abc import Callable, Sequence
from datetime import datetime
from pathlib import Path
from typing import Any, cast
from urllib.parse import urlsplit, urlunsplit
import httpx
import yaml
DEFAULT_CONFIG = Path(__file__).resolve().with_name("cli.yaml")
TOTAL_REQUEST_TIMEOUT_SECONDS = 120.0
SUCCESS_FIELDS = frozenset({"username", "password", "repo", "token"})
ERROR_FIELDS = frozenset({"code", "message", "request_id"})
REGISTER_ERROR_CODES = {
400: frozenset({"INVALID_REQUEST", "STUDENT_NOT_FOUND"}),
401: frozenset({"INVALID_REGISTRATION_TOKEN"}),
409: frozenset({"STUDENT_ALREADY_REGISTERED", "EMAIL_ALREADY_REGISTERED"}),
413: frozenset({"PAYLOAD_TOO_LARGE"}),
415: frozenset({"UNSUPPORTED_MEDIA_TYPE"}),
500: frozenset({"INTERNAL_ERROR"}),
502: frozenset({"GITEA_OPERATION_FAILED"}),
503: frozenset({"CONFIG_INVALID"}),
}
SUBMIT_ERROR_CODES = {
400: frozenset({"INVALID_REQUEST", "INVALID_ARCHIVE"}),
401: frozenset({"ACCESS_TOKEN_REQUIRED", "INVALID_ACCESS_TOKEN"}),
403: frozenset({"SUBMISSION_DEADLINE_EXPIRED"}),
404: frozenset({"ASSIGNMENT_NOT_FOUND"}),
409: frozenset({"SUBMISSION_IN_PROGRESS", "SOLUTION_UNCHANGED"}),
413: frozenset({"PAYLOAD_TOO_LARGE"}),
415: frozenset({"UNSUPPORTED_MEDIA_TYPE"}),
500: frozenset({"INTERNAL_ERROR"}),
502: frozenset({"GITEA_OPERATION_FAILED"}),
503: frozenset({"CONFIG_INVALID"}),
}
SUBMIT_SUCCESS_FIELDS = frozenset(
{"submission_id", "accepted_at", "late_submission", "workflow_run"}
)
ASSIGNMENT_ID_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,63}\Z")
_DRIVE_PATH_RE = re.compile(r"[A-Za-z]:")
_UUID_RE = re.compile(
r"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-"
r"[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\Z"
)
_RFC3339_RE = re.compile(
r"\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:[Zz]|[+-]\d{2}:\d{2})\Z"
)
_COPY_CHUNK_SIZE = 64 * 1024
MAX_PATH_SEGMENT_UTF8_BYTES = 255
MAX_SOLUTION_PATH_UTF8_BYTES = 1024
class CLIError(RuntimeError):
"""A safe command failure with its stable process exit code."""
def __init__(self, message: str, exit_code: int) -> None:
super().__init__(message)
self.exit_code = exit_code
class BackendRejected(CLIError):
"""A successfully validated backend error envelope."""
def __init__(self, message: str, exit_code: int, backend_code: str) -> None:
super().__init__(message, exit_code)
self.backend_code = backend_code
ClientFactory = Callable[[], httpx.Client]
def normalize_server_url(value: str) -> str:
"""Normalize an absolute root HTTP(S) backend URL without exposing user info."""
if (
not value
or any(char.isspace() or ord(char) < 0x20 or ord(char) == 0x7F for char in value)
or "?" in value
or "#" in value
):
raise CLIError("server URL is invalid", 2)
try:
parsed = urlsplit(value)
port = parsed.port
except ValueError as exc:
raise CLIError("server URL is invalid", 2) from exc
if (
parsed.scheme.lower() not in {"http", "https"}
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.path not in {"", "/"}
or parsed.query
or parsed.fragment
):
raise CLIError("server URL is invalid", 2)
host = parsed.hostname.lower()
if ":" in host:
host = f"[{host}]"
netloc = host if port is None else f"{host}:{port}"
normalized = urlunsplit((parsed.scheme.lower(), netloc, "", "", ""))
try:
httpx.URL(f"{normalized}/register")
except httpx.InvalidURL as exc:
raise CLIError("server URL is invalid", 2) from exc
return normalized
def _default_client() -> httpx.Client:
return httpx.Client(
timeout=httpx.Timeout(120.0, connect=10.0),
follow_redirects=False,
verify=True,
)
def _post_registration_with_deadline(
client_factory: ClientFactory,
url: str,
payload: dict[str, str],
total_timeout_seconds: float,
) -> httpx.Response:
outcomes: queue.SimpleQueue[httpx.Response | BaseException] = queue.SimpleQueue()
def send() -> None:
try:
with client_factory() as client:
outcomes.put(client.post(url, json=payload))
except BaseException as exc:
outcomes.put(exc)
worker = threading.Thread(target=send, name="distsys-cli-request", daemon=True)
worker.start()
worker.join(total_timeout_seconds)
if worker.is_alive():
raise CLIError("network request failed", 4)
outcome = outcomes.get()
if isinstance(outcome, BaseException):
raise outcome
return outcome
def _json_object(response: httpx.Response) -> dict[str, Any]:
content_type = response.headers.get("content-type", "").split(";", 1)[0].strip().lower()
if content_type != "application/json":
raise CLIError("backend returned an invalid response", 4)
try:
value = response.json()
except ValueError as exc:
raise CLIError("backend returned an invalid response", 4) from exc
if not isinstance(value, dict):
raise CLIError("backend returned an invalid response", 4)
return value
def _request_id(response: httpx.Response) -> str:
request_id = response.headers.get("X-Request-ID")
if not request_id:
raise CLIError("backend returned an invalid response", 4)
return str(request_id)
def _validate_success(response: httpx.Response) -> tuple[dict[str, str], str]:
request_id = _request_id(response)
if response.headers.get("Cache-Control") != "no-store":
raise CLIError("backend returned an invalid response", 4)
body = _json_object(response)
if set(body) != SUCCESS_FIELDS or any(
not isinstance(body.get(field), str) or not body[field] for field in SUCCESS_FIELDS
):
raise CLIError("backend returned an invalid response", 4)
repository = str(body["repo"])
try:
parsed_repository = urlsplit(repository)
except ValueError as exc:
raise CLIError("backend returned an invalid response", 4) from exc
if (
any(char.isspace() for char in repository)
or not parsed_repository.scheme
or not parsed_repository.netloc
):
raise CLIError("backend returned an invalid response", 4)
return {field: str(body[field]) for field in SUCCESS_FIELDS}, request_id
def _raise_backend_error(
response: httpx.Response,
allowed_errors: dict[int, frozenset[str]] = REGISTER_ERROR_CODES,
) -> None:
request_id = _request_id(response)
body = _json_object(response)
if (
set(body) != ERROR_FIELDS
or not all(isinstance(body.get(field), str) and body[field] for field in ERROR_FIELDS)
or body["request_id"] != request_id
):
raise CLIError("backend returned an invalid response", 4)
allowed_codes = allowed_errors.get(response.status_code)
if allowed_codes is None or body["code"] not in allowed_codes:
raise CLIError("backend returned an invalid response", 4)
status_class = response.status_code // 100
exit_code = 3 if status_class == 4 else 4
raise BackendRejected(
f"{body['message']} (request ID: {request_id})",
exit_code,
str(body["code"]),
)
class _StrictLoader(yaml.SafeLoader):
"""YAML loader that rejects duplicate mapping keys."""
def _strict_mapping(loader: _StrictLoader, node: yaml.MappingNode, deep: bool = False) -> Any:
loader.flatten_mapping(node)
result: dict[Any, Any] = {}
for key_node, value_node in node.value:
key = loader.construct_object(key_node, deep=deep)
try:
duplicate = key in result
except TypeError as exc:
raise yaml.constructor.ConstructorError(
None, None, "mapping key is not hashable", key_node.start_mark
) from exc
if duplicate:
raise yaml.constructor.ConstructorError(
None, None, "duplicate mapping key", key_node.start_mark
)
result[key] = loader.construct_object(value_node, deep=deep)
return result
_StrictLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _strict_mapping)
def _load_config(config_path: Path | None) -> tuple[str, str]:
path = DEFAULT_CONFIG if config_path is None else config_path
if config_path is not None and not path.is_absolute():
path = Path.cwd() / path
try:
documents = list(yaml.load_all(path.read_text(encoding="utf-8"), Loader=_StrictLoader))
except (OSError, UnicodeError, yaml.YAMLError) as exc:
raise CLIError("configuration is invalid", 2) from exc
if len(documents) != 1 or not isinstance(documents[0], dict):
raise CLIError("configuration is invalid", 2)
value = documents[0]
if set(value) - {"server_url", "access_token"} or "server_url" not in value:
raise CLIError("configuration is invalid", 2)
server_url = value.get("server_url")
access_token = value.get("access_token")
if not isinstance(server_url, str) or not isinstance(access_token, str) or not access_token:
raise CLIError("configuration is invalid or has no access token", 2)
return normalize_server_url(server_url), access_token
def _normalized_segment(value: str) -> str:
if (
not value
or value in {".", ".."}
or "\\" in value
or _DRIVE_PATH_RE.match(value)
or any(unicodedata.category(character) == "Cc" for character in value)
):
raise CLIError("solution contains an unsupported path", 2)
normalized = unicodedata.normalize("NFC", value)
if (
normalized in {"", ".", "..", ".git"}
or len(normalized.encode("utf-8")) > MAX_PATH_SEGMENT_UTF8_BYTES
):
raise CLIError("solution contains an unsupported path", 2)
return normalized
def _validate_solution_path(path: tuple[str, ...]) -> None:
if len("/".join(path).encode("utf-8")) > MAX_SOLUTION_PATH_UTF8_BYTES:
raise CLIError("solution contains an unsupported path", 2)
def _collect_solution_entries(
source: Path,
) -> list[tuple[Path, tuple[str, ...], bool, os.stat_result]]:
entries: list[tuple[Path, tuple[str, ...], bool, os.stat_result]] = []
normalized_paths: dict[tuple[str, ...], bool] = {}
def visit(directory: Path, relative: tuple[str, ...]) -> None:
try:
children = sorted(os.scandir(directory), key=lambda entry: entry.name)
except OSError as exc:
raise CLIError("solution directory cannot be read", 2) from exc
for child in children:
normalized = (*relative, _normalized_segment(child.name))
_validate_solution_path(normalized)
try:
metadata = child.stat(follow_symlinks=False)
except OSError as exc:
raise CLIError("solution entry cannot be read", 2) from exc
mode = metadata.st_mode
if stat.S_ISLNK(mode):
raise CLIError("solution contains a symbolic link", 2)
directory_entry = stat.S_ISDIR(mode)
regular = stat.S_ISREG(mode)
if not directory_entry and not regular:
raise CLIError("solution contains a non-regular entry", 2)
if regular and getattr(metadata, "st_nlink", 1) > 1:
raise CLIError("solution contains a hard-linked file", 2)
if normalized in normalized_paths:
raise CLIError("solution contains duplicate normalized paths", 2)
for length in range(1, len(normalized)):
ancestor = normalized[:length]
if ancestor in normalized_paths and not normalized_paths[ancestor]:
raise CLIError("solution contains a file/directory conflict", 2)
normalized_paths[normalized] = directory_entry
entries.append((Path(child.path), normalized, directory_entry, metadata))
if directory_entry:
visit(Path(child.path), normalized)
visit(source, ())
if not any(not directory for _, _, directory, _ in entries):
raise CLIError("solution directory contains no regular files", 2)
return entries
def _zip_info(name: str, mode: int, *, directory: bool) -> zipfile.ZipInfo:
info = zipfile.ZipInfo(name + ("/" if directory else ""))
info.create_system = 3
file_type = stat.S_IFDIR if directory else stat.S_IFREG
info.external_attr = (file_type | mode) << 16
info.compress_type = zipfile.ZIP_DEFLATED
return info
def _source_is_executable(mode: int, platform_name: str = os.name) -> bool:
"""Return whether this platform exposes any executable bit for a source file."""
return platform_name == "posix" and bool(mode & 0o111)
def _resolve_solution_directory(solution_dir: Path) -> Path:
try:
source = solution_dir.resolve(strict=True)
except OSError as exc:
raise CLIError("solution directory does not exist", 2) from exc
if not source.is_dir():
raise CLIError("solution path is not a directory", 2)
return source
def build_solution_archive(solution_dir: Path, destination: Path) -> None:
"""Build one normalized, link-free archive below a synthetic solution root."""
source = _resolve_solution_directory(solution_dir)
entries = _collect_solution_entries(source)
try:
with zipfile.ZipFile(destination, "w", allowZip64=True) as archive:
archive.writestr(_zip_info("solution", 0o755, directory=True), b"")
for path, relative, directory, selected_metadata in entries:
member_name = "solution/" + "/".join(relative)
if directory:
archive.writestr(_zip_info(member_name, 0o755, directory=True), b"")
continue
executable = _source_is_executable(selected_metadata.st_mode)
permissions = 0o755 if executable else 0o644
flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
descriptor = os.open(path, flags)
try:
current = os.fstat(descriptor)
if getattr(current, "st_nlink", 1) > 1:
raise CLIError("solution contains a hard-linked file", 2)
identity_changed = os.name == "posix" and (
current.st_dev != selected_metadata.st_dev
or current.st_ino != selected_metadata.st_ino
)
metadata_changed = (
current.st_size != selected_metadata.st_size
or current.st_mtime_ns != selected_metadata.st_mtime_ns
)
if not stat.S_ISREG(current.st_mode) or identity_changed or metadata_changed:
raise CLIError("solution changed while it was archived", 2)
with os.fdopen(descriptor, "rb", closefd=True) as source_file:
descriptor = -1
info = _zip_info(member_name, permissions, directory=False)
with archive.open(info, "w") as output:
while chunk := source_file.read(_COPY_CHUNK_SIZE):
output.write(chunk)
final = os.fstat(source_file.fileno())
if (
final.st_size != current.st_size
or final.st_mtime_ns != current.st_mtime_ns
):
raise CLIError("solution changed while it was archived", 2)
finally:
if descriptor >= 0:
os.close(descriptor)
except CLIError:
raise
except (OSError, ValueError, zipfile.BadZipFile) as exc:
raise CLIError("solution archive could not be created", 1) from exc
def _post_archive_with_deadline(
client_factory: ClientFactory,
url: str,
archive_path: Path,
access_token: str,
total_timeout_seconds: float,
) -> httpx.Response:
outcomes: queue.SimpleQueue[httpx.Response | BaseException] = queue.SimpleQueue()
# Windows does not permit unlinking a file held open by the request worker.
# Detach the request body from the temporary path before enforcing the outer
# wall-clock deadline so timeout cleanup remains immediate.
windows_content = archive_path.read_bytes() if os.name == "nt" else None
headers = {
"Authorization": f"token {access_token}",
"Content-Type": "application/zip",
}
def send() -> None:
try:
with client_factory() as client:
if windows_content is not None:
outcomes.put(client.post(url, content=windows_content, headers=headers))
else:
with archive_path.open("rb") as content:
outcomes.put(client.post(url, content=content, headers=headers))
except BaseException as exc:
outcomes.put(exc)
finally:
_remove_temporary_archive(archive_path)
worker = threading.Thread(target=send, name="distsys-cli-request", daemon=True)
worker.start()
worker.join(total_timeout_seconds)
if worker.is_alive():
raise CLIError("network request failed", 4)
outcome = outcomes.get()
if isinstance(outcome, BaseException):
raise outcome
return outcome
def _remove_temporary_archive(path: Path) -> None:
try:
path.unlink(missing_ok=True)
except OSError:
pass
def _validate_submit_success(response: httpx.Response) -> dict[str, str | bool]:
_request_id(response)
body = _json_object(response)
if set(body) != SUBMIT_SUCCESS_FIELDS:
raise CLIError("backend returned an invalid response", 4)
submission_id = body.get("submission_id")
accepted_at = body.get("accepted_at")
late = body.get("late_submission")
workflow_run = body.get("workflow_run")
if not isinstance(submission_id, str) or not submission_id:
raise CLIError("backend returned an invalid response", 4)
if not isinstance(accepted_at, str) or not accepted_at:
raise CLIError("backend returned an invalid response", 4)
if not isinstance(workflow_run, str) or not workflow_run:
raise CLIError("backend returned an invalid response", 4)
if not isinstance(late, bool):
raise CLIError("backend returned an invalid response", 4)
if _UUID_RE.fullmatch(submission_id) is None or _RFC3339_RE.fullmatch(accepted_at) is None:
raise CLIError("backend returned an invalid response", 4)
try:
uuid.UUID(submission_id)
normalized_time = accepted_at[:-1] + "+00:00" if accepted_at[-1] in "Zz" else accepted_at
parsed_time = datetime.fromisoformat(normalized_time)
parsed_url = urlsplit(workflow_run)
workflow_port = parsed_url.port
parsed_http_url = httpx.URL(workflow_run)
except (UnicodeError, ValueError, TypeError, httpx.InvalidURL) as exc:
raise CLIError("backend returned an invalid response", 4) from exc
if (
parsed_time.tzinfo is None
or accepted_at.endswith("-00:00")
or parsed_url.scheme not in {"http", "https"}
or not parsed_url.netloc
or parsed_url.hostname is None
or parsed_url.username is not None
or parsed_url.password is not None
or workflow_port == 0
or not parsed_http_url.host
or any(
character.isspace() or ord(character) < 0x20 or ord(character) == 0x7F
for character in workflow_run
)
):
raise CLIError("backend returned an invalid response", 4)
return {
"submission_id": submission_id,
"accepted_at": accepted_at,
"late_submission": late,
"workflow_run": workflow_run,
}
def _format_accepted_at(accepted_at: str) -> str:
normalized_time = accepted_at[:-1] + "+00:00" if accepted_at[-1] in "Zz" else accepted_at
return datetime.fromisoformat(normalized_time).strftime("%Y-%m-%d %H:%M:%S")
def submit(
assignment_id: str,
solution_dir: Path,
config_path: Path | None,
*,
client_factory: ClientFactory = _default_client,
total_timeout_seconds: float = TOTAL_REQUEST_TIMEOUT_SECONDS,
) -> None:
if ASSIGNMENT_ID_RE.fullmatch(assignment_id) is None:
raise CLIError("assignment ID is invalid", 2)
server_url, access_token = _load_config(config_path)
resolved_solution = _resolve_solution_directory(solution_dir)
descriptor, archive_name = tempfile.mkstemp(prefix="distsys-solution-", suffix=".zip")
os.close(descriptor)
archive_path = Path(archive_name)
try:
try:
archive_path.resolve().relative_to(resolved_solution)
except ValueError:
pass
else:
raise CLIError("temporary archive location overlaps the solution directory", 2)
build_solution_archive(resolved_solution, archive_path)
try:
response = _post_archive_with_deadline(
client_factory,
f"{server_url}/submit/{assignment_id}",
archive_path,
access_token,
total_timeout_seconds,
)
except (httpx.HTTPError, httpx.InvalidURL, OSError) as exc:
raise CLIError("network request failed", 4) from exc
if response.status_code != 201:
try:
_raise_backend_error(response, SUBMIT_ERROR_CODES)
except BackendRejected as exc:
if exc.backend_code == "SOLUTION_UNCHANGED":
raise CLIError(f"{exc}; no grading run was created", 3) from None
raise
result = _validate_submit_success(response)
finally:
_remove_temporary_archive(archive_path)
print(f"Submission ID: {result['submission_id']}")
print(f"Accepted at: {_format_accepted_at(cast(str, result['accepted_at']))}")
print(f"Late submission: {str(result['late_submission']).lower()}")
print(f"Workflow run: {result['workflow_run']}")
def _restrict_windows(path: Path) -> bool:
creation_flags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
try:
identity = subprocess.run(
["whoami"],
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
creationflags=creation_flags,
)
principal = identity.stdout.strip()
if identity.returncode != 0 or not principal:
return False
completed = subprocess.run(
[
"icacls",
str(path),
"/inheritance:r",
"/grant:r",
f"{principal}:(F)",
],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
creationflags=creation_flags,
)
except OSError:
return False
return completed.returncode == 0
def _write_config(destination: Path, server_url: str, access_token: str) -> bool:
"""Synchronize a private sibling temporary file, then atomically replace."""
payload = yaml.safe_dump(
{"server_url": server_url, "access_token": access_token},
allow_unicode=True,
sort_keys=False,
).encode("utf-8")
descriptor = -1
temporary: Path | None = None
permission_warning = False
try:
descriptor, temporary_name = tempfile.mkstemp(
prefix=f".{destination.name}.", dir=destination.parent
)
temporary = Path(temporary_name)
with os.fdopen(descriptor, "wb", closefd=True) as stream:
descriptor = -1
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
if os.name == "posix":
temporary.chmod(stat.S_IRUSR | stat.S_IWUSR)
if os.name == "nt" and not _restrict_windows(temporary):
permission_warning = True
os.replace(temporary, destination)
temporary = None
finally:
if descriptor >= 0:
os.close(descriptor)
if temporary is not None:
try:
temporary.unlink()
except OSError:
pass
return permission_warning
def _resolve_destination(value: Path | None) -> Path:
destination = DEFAULT_CONFIG if value is None else value
if value is not None and not destination.is_absolute():
destination = Path.cwd() / destination
try:
destination = destination.resolve(strict=False)
destination.parent.mkdir(parents=True, exist_ok=True)
except OSError as exc:
raise CLIError("configuration destination is not usable", 2) from exc
if destination.exists() and destination.is_dir():
raise CLIError("configuration destination is not a file", 2)
return destination
def _masked_email(email: str) -> str:
local, separator, domain = email.rpartition("@")
if not separator or not local or not domain:
return "***"
return f"{local[0]}***@{domain}"
def register(
server_url: str,
config_path: Path | None,
*,
input_fn: Callable[[str], str] = input,
password_fn: Callable[[str], str] = getpass.getpass,
client_factory: ClientFactory = _default_client,
total_timeout_seconds: float = TOTAL_REQUEST_TIMEOUT_SECONDS,
) -> None:
normalized_url = normalize_server_url(server_url)
destination = _resolve_destination(config_path)
registration_token = password_fn("Registration token: ")
name = input_fn("Full name: ")
email = input_fn("Email: ")
try:
response = _post_registration_with_deadline(
client_factory,
f"{normalized_url}/register",
{"name": name, "email": email, "reg_token": registration_token},
total_timeout_seconds,
)
except (httpx.HTTPError, httpx.InvalidURL) as exc:
raise CLIError("network request failed", 4) from exc
if response.status_code != 201:
_raise_backend_error(response)
result, request_id = _validate_success(response)
try:
permission_warning = _write_config(destination, normalized_url, result["token"])
except OSError as exc:
raise CLIError(
"Registration succeeded, but credentials could not be stored. "
f"Contact course staff with request ID: {request_id}",
1,
) from exc
if permission_warning:
print(
"warning: could not restrict configuration permissions to the current user",
file=sys.stderr,
)
print(f"Username: {result['username']}")
print(f"Temporary password: {result['password']}")
print(f"Repository: {result['repo']}")
print(f"Registration email: Check {_masked_email(email)} for your Gitea account details.")
print("If it does not arrive within 5 minutes, check spam and contact course staff.")
print("Action required: Sign in to Gitea and change the temporary password before submitting.")
def parse_args(arguments: Sequence[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--config", type=Path, default=None)
subparsers = parser.add_subparsers(dest="command", required=True)
register_parser = subparsers.add_parser("register", help="create a student account")
register_parser.add_argument("--config", type=Path, default=argparse.SUPPRESS)
register_parser.add_argument("server_url")
submit_parser = subparsers.add_parser("submit", help="submit a solution for grading")
submit_parser.add_argument("--config", type=Path, default=argparse.SUPPRESS)
submit_parser.add_argument("--assignment-id")
submit_parser.add_argument("--solution-dir", type=Path)
return parser.parse_args(arguments)
def _resolve_submit_options(
assignment_id: str | None, solution_dir: Path | None
) -> tuple[str, Path]:
if assignment_id is None and solution_dir is None:
working_directory = Path.cwd()
default_solution = working_directory / "solution"
if not default_solution.is_dir():
raise CLIError("submit without options requires a solution subdirectory", 2)
return working_directory.name, default_solution
if assignment_id is None or solution_dir is None:
raise CLIError("--assignment-id and --solution-dir must be provided together", 2)
return assignment_id, solution_dir
def main(arguments: Sequence[str] | None = None) -> int:
args = parse_args(arguments)
try:
if args.command == "register":
register(args.server_url, args.config)
elif args.command == "submit":
assignment_id, solution_dir = _resolve_submit_options(
args.assignment_id, args.solution_dir
)
submit(assignment_id, solution_dir, args.config)
except CLIError as exc:
print(str(exc), file=sys.stderr)
return exc.exit_code
return 0
if __name__ == "__main__":
raise SystemExit(main())
+92
View File
@@ -0,0 +1,92 @@
# Домашние задания
Здесь находятся домашние задания по курсу. Если вы зачислены на курс и будете сдавать задания, прочитайте внимательно текст ниже. **Оценка за решение выставляется только в том случае, если оно сдано в тестирующую систему.** Если вы вольнослушатель, то сдача заданий вам недоступна, но вы можете тестировать решения локально (см. ниже).
## Самостоятельность выполнения
Домашние задания выполняются индивидуально. Можно обсуждать условие, общие идеи и подходы, но нельзя обмениваться кодом, отчётом или другими частями решений.
Нельзя передавать другим студентам своё решение или его части, просматривать решения других студентов, совместно разрабатывать, копировать или адаптировать их, в том числе если эти решения были сданы в прошлые годы.
При установленном нарушении академических норм за соответствующее домашнее задание выставляется оценка 0, а преподаватель направляет служебную записку декану факультета. Студенту, намеренно предоставившему своё решение для списывания, также может быть выставлена оценка 0. Подробнее см. [правила НИУ ВШЭ о нарушении академических норм](https://www.hse.ru/studyspravka/academnormy).
## Использование LLM
При выполнении домашних заданий можно использовать LLM. Если их применение повлияло на сданное решение, его необходимо описать в разделе `## Описание применения генеративных моделей` в файле `solution/readme.md`. Если раздел отсутствует, считается, что генеративные модели при подготовке решения не использовались. Во время устной защиты использовать LLM нельзя, если преподаватель явно не разрешил обратное. Подробные правила приведены в [политике использования LLM](../llm-policy.md).
## Настройка окружения
Убедитесь, что у вас на компьютере установлен Python. В случае работы на Windows не рекомендуется устанавливать Python из Microsoft Store – это может привести к ошибкам при запуске тестов, поэтому лучше использовать [официальный установщик](https://www.python.org/downloads).
Установите Python 3.12 или новее и необходимые зависимости: `pip3 install -r requirements.txt`. При работе на Windows замените здесь и далее `pip3` и `python3` на `pip` и `python` соответственно.
Также вам понадобится запускать Docker-контейнеры. Для этого надо установить [Docker Engine](https://docs.docker.com/engine/install/) или [Docker Desktop](https://docs.docker.com/desktop/) под вашу ОС.
Склонируйте репозиторий курса в папку на вашем компьютере. Далее обновляйте вашу копию репозитория с помощью `git pull` по мере размещения новых заданий и материалов. Во избежание проблем не рекомендуется менять структуру папок внутри репозитория и переносить их в другое место.
Откройте корень репозитория в вашей IDE и откройте файл `homework/01-guarantees/solution/guarantees.py`. Для автодополнения и проверки типов API AnySystem описан в [стабе](../typings/anysystem/__init__.pyi) — файле с объявлениями классов и методов без их реализации. В VS Code с Pylance он подключается через [настройки репозитория](../pyrightconfig.json), дополнительная установка модуля не нужна. Для другой IDE укажите папку `typings` как каталог стабов согласно её документации. Исходная реализация API доступна в [AnySystem 0.3.0](https://github.com/osukhoroslov/anysystem/blob/v0.3.0/python/anysystem.py).
В AnySystem 0.3.0 Python-модуль встроен в Rust-тестер: при запуске тестов локально или в Docker отдельный `anysystem.py` и настройка `PYTHONPATH` не требуются. Запускайте тесты из папки задания, чтобы относительные пути к решениям были корректны. Стаб служит только для IDE.
### Регистрация
(Пропустите эту часть, если вы вольнослушатель.)
Перед тем, как сдавать задания, надо пройти регистрацию. Откройте папку `homework` и выполните команду:
```
python3 cli.py register https://distsys.ru
```
Введите registration token, разосланный в канале курса, полное имя (Фамилия Имя Отчество) в точности как в списке студентов и личный адрес электронной почты. Если вас нет в списке студентов, зачисленных на курс, то вы не можете сдавать задания.
После успешной регистрации настройки и токен доступа будут сохранены в файле `cli.yaml`. Не публикуйте этот файл и не добавляйте его в Git. На указанный адрес придёт письмо с данными учётной записи Gitea.
Войдите в Gitea с временным паролем из вывода команды и сразу смените его. До смены пароля токен не работает. Если токен утрачен, создайте новый в Gitea: **Settings -> Applications**, имя `distsys-cli`, разрешение **User: Read**; затем замените только `access_token` в `cli.yaml`. Если недоступен адрес электронной почты или письмо не приходит, обратитесь к преподавателям.
## Локальное тестирование решения
Перед сдачей решения в тестирующую систему обязательно проведите локальное тестирование. В каждом задании будет инструкция как это сделать.
**Пожалуйста, не пренебрегайте локальным тестированием.** Все тесты для заданий открыты, в том числе для того, чтобы вы могли быстро протестировать решение локально после каждой правки, а не отправляли его каждый раз в тестирующую систему и ждали в очереди. Тем самым вы также снизите время ожидания для товарищей, которым действительно надо зафиксировать улучшение своего решения.
В каждом условии приведена Docker-команда для запуска тестов в окружении, аналогичном тестирующей системе. Выполняйте её из папки соответствующего задания.
В некоторых случаях время выполнения тестов, особенно в Docker, может зависеть от вашей системы, архитектуры процессора и т.п. Если в тестах к заданию есть лимит по времени, и при локальном тестировании ваше решение не укладывается в него, попробуйте сдать решение в систему. Возможно тесты там пройдут из-за отличий в скорости выполнения. Референсом в таких тестах служит сервер тестирующей системы. Также можно попробовать запустить тесты локально без Docker по инструкции из условия задания.
### ARM64 / Apple Silicon
Опубликованные образы тестов сейчас предназначены для `linux/amd64`. На компьютерах с ARM64, включая Apple Silicon, Docker запускает их через эмуляцию, поэтому тесты могут выполняться заметно медленнее. Особенно это важно для тестов с лимитом времени: их локальный результат может отличаться от результата в тестирующей системе.
Для заданий 1, 4, 6–9 можно собрать тестовый образ для архитектуры своей машины из открытого `tests/Dockerfile`. Выполните из папки задания:
```bash
docker build --pull -t distsys-tests-local ./tests
docker run --rm -t -v ./solution:/solution distsys-tests-local [ЗДЕСЬ МОЖНО УКАЗАТЬ ОПЦИИ]
```
Для полного прогона замените `[ЗДЕСЬ МОЖНО УКАЗАТЬ ОПЦИИ]` параметрами из команды полного тестирования в условии соответствующего задания. Для быстрых итераций можно запускать тесты без Docker по инструкции из условия.
В заданиях 2, 3, 5 и 10 используйте приведённые в условиях инструкции локального запуска сервисов и тестов. Собирать внутренний Docker-in-Docker образ для этого не требуется.
Тестирующая система используется для итоговой проверки и служит эталоном для временных лимитов, но не должна заменять локальное тестирование после каждой правки.
## Сдача решения
После локального тестирования откройте папку задания, например `homework/01-guarantees`, и выполните команду:
```
python3 ../cli.py submit
```
Утилита определит идентификатор задания по имени текущей папки и отправит содержимое её поддиректории `solution`. Она выведет идентификатор и официальное время приёма решения, а также ссылку на запуск workflow. Откройте эту ссылку в Gitea, чтобы следить за проверкой и прочитать её журнал.
В состав решения для каждого задания должен входить краткий отчёт в файле `solution/readme.md`. Отчёт необходимо сдать вместе с остальными файлами решения до дедлайна задания. При его отсутствии автоматические тесты всё равно запускаются, но защита не проводится и **решение не засчитывается**. Требования к содержанию отчёта приведены в условии соответствующего задания.
## Изоляция процессов AnySystem
В заданиях на AnySystem каждый экземпляр `Process` моделирует отдельный процесс распределённой системы. Его изменяемое состояние должно храниться только в атрибутах экземпляра (`self`). Все взаимодействия между процессами должны происходить через сообщения, как и в реальной распределённой системе.
Запрещено использовать общую изменяемую память: изменяемые объекты на уровне модуля или любого класса, включая вспомогательные классы, изменяемые значения аргументов по умолчанию, `global`, разделяемые замыкания, атрибуты классов и рефлексию для изменения глобального состояния. Перед запуском тестов решение статически проверяется; при нарушении тестер выводит найденные места и выставляет `SCORE: 0`.
Импорты, вспомогательные функции и классы, а также неизменяемые константы разрешены. Изменяемое состояние вспомогательного объекта допустимо, если каждый процесс создаёт собственный экземпляр и хранит его в `self`; для полей `dataclass` используйте `field(default_factory=...)`. Неизменяемые последовательности и множества можно задавать через `tuple` и `frozenset`, а таблицы — через `types.MappingProxyType` с литералом словаря.
+2
View File
@@ -0,0 +1,2 @@
httpx>=0.28,<1
PyYAML>=6.0.2,<7