From e29c7eb39ec56dec975fc2a65612ba58f04f9a57 Mon Sep 17 00:00:00 2001 From: Oleg Sukhoroslov Date: Thu, 10 Sep 2026 13:22:31 +0300 Subject: [PATCH] Add homework 01 guarantees assignment --- README.md | 3 +- homework/01-guarantees/readme.md | 130 ++ homework/01-guarantees/solution/guarantees.py | 177 ++ homework/01-guarantees/tests/.dockerignore | 5 + homework/01-guarantees/tests/Cargo.lock | 990 +++++++++ homework/01-guarantees/tests/Cargo.toml | 17 + homework/01-guarantees/tests/Dockerfile | 19 + homework/01-guarantees/tests/rustfmt.toml | 2 + .../tests/shared_state_validator.py | 1890 +++++++++++++++++ homework/01-guarantees/tests/src/common.rs | 285 +++ homework/01-guarantees/tests/src/main.rs | 310 +++ .../01-guarantees/tests/src/shared_state.rs | 636 ++++++ homework/01-guarantees/tests/src/tests.rs | 158 ++ homework/01-guarantees/tests/src/tests_mc.rs | 148 ++ homework/cli.py | 750 +++++++ homework/readme.md | 92 + homework/requirements.txt | 2 + 17 files changed, 5613 insertions(+), 1 deletion(-) create mode 100644 homework/01-guarantees/readme.md create mode 100644 homework/01-guarantees/solution/guarantees.py create mode 100644 homework/01-guarantees/tests/.dockerignore create mode 100644 homework/01-guarantees/tests/Cargo.lock create mode 100644 homework/01-guarantees/tests/Cargo.toml create mode 100644 homework/01-guarantees/tests/Dockerfile create mode 100644 homework/01-guarantees/tests/rustfmt.toml create mode 100644 homework/01-guarantees/tests/shared_state_validator.py create mode 100644 homework/01-guarantees/tests/src/common.rs create mode 100644 homework/01-guarantees/tests/src/main.rs create mode 100644 homework/01-guarantees/tests/src/shared_state.rs create mode 100644 homework/01-guarantees/tests/src/tests.rs create mode 100644 homework/01-guarantees/tests/src/tests_mc.rs create mode 100644 homework/cli.py create mode 100644 homework/readme.md create mode 100644 homework/requirements.txt diff --git a/README.md b/README.md index 382f5db..0decafb 100644 --- a/README.md +++ b/README.md @@ -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) - Форма для фидбека по курсу ## Занятия и преподаватели diff --git a/homework/01-guarantees/readme.md b/homework/01-guarantees/readme.md new file mode 100644 index 0000000..cb33fdf --- /dev/null +++ b/homework/01-guarantees/readme.md @@ -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. diff --git a/homework/01-guarantees/solution/guarantees.py b/homework/01-guarantees/solution/guarantees.py new file mode 100644 index 0000000..e2bd36b --- /dev/null +++ b/homework/01-guarantees/solution/guarantees.py @@ -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 diff --git a/homework/01-guarantees/tests/.dockerignore b/homework/01-guarantees/tests/.dockerignore new file mode 100644 index 0000000..d291891 --- /dev/null +++ b/homework/01-guarantees/tests/.dockerignore @@ -0,0 +1,5 @@ +target +**/target +**/__pycache__ +**/.pytest_cache +*.pyc diff --git a/homework/01-guarantees/tests/Cargo.lock b/homework/01-guarantees/tests/Cargo.lock new file mode 100644 index 0000000..b4abc47 --- /dev/null +++ b/homework/01-guarantees/tests/Cargo.lock @@ -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", +] diff --git a/homework/01-guarantees/tests/Cargo.toml b/homework/01-guarantees/tests/Cargo.toml new file mode 100644 index 0000000..704c001 --- /dev/null +++ b/homework/01-guarantees/tests/Cargo.toml @@ -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" diff --git a/homework/01-guarantees/tests/Dockerfile b/homework/01-guarantees/tests/Dockerfile new file mode 100644 index 0000000..5b9d599 --- /dev/null +++ b/homework/01-guarantees/tests/Dockerfile @@ -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"] diff --git a/homework/01-guarantees/tests/rustfmt.toml b/homework/01-guarantees/tests/rustfmt.toml new file mode 100644 index 0000000..66392f7 --- /dev/null +++ b/homework/01-guarantees/tests/rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 120 +newline_style = "Unix" diff --git a/homework/01-guarantees/tests/shared_state_validator.py b/homework/01-guarantees/tests/shared_state_validator.py new file mode 100644 index 0000000..4f47a41 --- /dev/null +++ b/homework/01-guarantees/tests/shared_state_validator.py @@ -0,0 +1,1890 @@ +"""Static checks for Python state shared between AnySystem processes. + +The checker parses source files only. It never imports or executes a student's +solution. Local imports which can be resolved next to the entrypoint are checked +recursively; calls through external imports are limited to explicitly reviewed APIs. +""" + +from __future__ import annotations + +import ast +from pathlib import Path +from typing import Callable, Iterable + + +MUTABLE_NODES = ( + ast.List, + ast.ListComp, + ast.Dict, + ast.DictComp, + ast.Set, + ast.SetComp, + ast.GeneratorExp, +) +MUTABLE_CONSTRUCTORS = { + "builtins.bytearray", + "builtins.dict", + "builtins.list", + "builtins.set", + "collections.ChainMap", + "collections.Counter", + "collections.OrderedDict", + "collections.defaultdict", + "collections.deque", + "weakref.WeakKeyDictionary", + "weakref.WeakValueDictionary", +} +IMMUTABLE_SCALAR_CONSTRUCTORS = { + "builtins.bool", + "builtins.bytes", + "builtins.complex", + "builtins.float", + "builtins.int", + "builtins.range", + "builtins.str", +} +KNOWN_IMMUTABLE_CALLS = { + "os.getcwd", + "os.path.abspath", + "os.path.basename", + "os.path.dirname", + "os.path.join", + "os.path.normpath", + "os.path.realpath", + "random.randint", + "random.randrange", +} +TYPE_SUBSCRIPT_NAMES = { + "builtins.dict", + "builtins.frozenset", + "builtins.list", + "builtins.set", + "builtins.tuple", + "builtins.type", + "collections.abc.Mapping", + "collections.abc.Sequence", + "typing.Annotated", + "typing.Callable", + "typing.ClassVar", + "typing.Dict", + "typing.FrozenSet", + "typing.List", + "typing.Literal", + "typing.Mapping", + "typing.Optional", + "typing.Sequence", + "typing.Set", + "typing.Tuple", + "typing.Type", + "typing.Union", +} +TYPE_FACTORIES = { + "collections.namedtuple", + "typing.NewType", + "typing.ParamSpec", + "typing.TypeVar", +} +ENUM_BASE_NAMES = { + "enum.Enum", + "enum.Flag", + "enum.IntEnum", + "enum.IntFlag", + "enum.StrEnum", +} +MUTATOR_METHODS = frozenset( + "__delitem__ __setitem__ add append clear discard extend insert move_to_end " + "pop popitem remove reverse rotate setdefault sort update".split() +) +UNBOUND_MUTATOR_METHODS = {"__delattr__", "__delitem__", "__setattr__", "__setitem__"} +OPERATOR_MUTATORS = frozenset( + f"operator.{name}" + for name in "delitem iadd iand iconcat imatmul imul ior isub ixor setitem".split() +) +STATEFUL_DECORATORS = {"functools.cache", "functools.lru_cache"} +# Calls through imported objects are denied unless explicitly reviewed for the +# course. This is deliberately an allowlist: method names alone cannot +# distinguish random.seed() from APIs which construct values, transform their +# arguments, or provide the limited random operations used by the assignments. +ALLOWED_IMPORTED_CALLS = frozenset( + """ + anysystem.Message anysystem.Message.from_json anysystem.python.anysystem.Message + array.array ast.literal_eval + bisect.bisect bisect.bisect_left bisect.bisect_right bisect.insort builtins.print + collections.defaultdict collections.deque collections.namedtuple copy.copy copy.deepcopy + dataclasses.asdict dataclasses.field dataclasses.fields dataclasses.is_dataclass + functools.cmp_to_key functools.reduce + hashlib.blake2b hashlib.md5 hashlib.sha1 hashlib.sha512 hashlib.sha256 + hashlib.sha3_256 hashlib.shake_256 + heapq.heapify heapq.heappop heapq.heappush heapq.merge importlib.import_module + inspect.signature itertools.count json.dumps json.loads + logging.critical logging.debug logging.error logging.exception logging.info logging.log + logging.warning math.ceil math.log math.log2 math.sqrt + os.getcwd os.getenv os.path.abspath os.path.basename os.path.dirname os.path.join + os.path.normpath os.path.realpath pathlib.Path pickle.dumps pickle.loads + portion.closedopen portion.empty pprint.pprint pydantic.Field + random.Random random.choice random.choices random.randint random.random random.randrange + random.sample random.shuffle random.uniform re.fullmatch sys.intern threading.Lock + types.MappingProxyType time.time typing.cast uuid.uuid4 + """.split() +) +IMPORTED_ARGUMENT_MUTATORS = set( + "bisect.insort heapq.heapify heapq.heappop heapq.heappush random.shuffle".split() +) +ALLOWED_MUTABLE_METADATA = {"__all__"} +TRUSTED_STAR_EXPORTS = { + "dataclasses": {"dataclass", "field"}, + "enum": {"Enum", "Flag", "IntEnum", "IntFlag", "StrEnum", "auto"}, + "functools": {"cache", "lru_cache"}, + "types": {"MappingProxyType"}, + "typing": { + *"Annotated Any Callable ClassVar Dict FrozenSet List Literal Mapping NewType " + "Optional ParamSpec Sequence Set Tuple Type TypeVar Union".split(), + }, +} +TRUSTED_BUILTINS = set( + "bool bytearray bytes complex dict float frozenset int list range set str tuple type".split() +) + + +def _resolved_attribute(node: ast.AST) -> tuple[ast.AST, str] | None: + if isinstance(node, ast.Attribute): + return node.value, node.attr + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "getattr" + and len(node.args) >= 2 + and isinstance(node.args[1], ast.Constant) + and isinstance(node.args[1].value, str) + ): + return node.args[0], node.args[1].value + return None + + +def _call_name(node: ast.AST) -> str | None: + if isinstance(node, ast.Name): + return node.id + resolved = _resolved_attribute(node) + return resolved[1] if resolved is not None else None + + +def _symbol(node: ast.AST, symbols: dict[str, str]) -> str | None: + if isinstance(node, ast.Name): + return symbols.get(node.id) + resolved = _resolved_attribute(node) + if resolved is not None: + value, attribute = resolved + parent = _symbol(value, symbols) + return f"{parent}.{attribute}" if parent is not None else None + return None + + +def _root_name(node: ast.AST) -> str | None: + while (resolved := _resolved_attribute(node)) is not None: + node = resolved[0] + return node.id if isinstance(node, ast.Name) else None + + +def _assignment_parts(node: ast.AST) -> tuple[ast.AST, list[ast.AST]] | None: + if isinstance(node, ast.Assign): + return node.value, node.targets + if isinstance(node, (ast.AnnAssign, ast.NamedExpr)) and node.value is not None: + return node.value, [node.target] + return None + + +def _call_returns_shared( + node: ast.AST, + is_shared: Callable[[ast.AST], bool], + shared_functions: set[str], + returned_parameters: dict[str, set[int]], +) -> bool: + if not isinstance(node, ast.Call): + return False + name = node.func.id if isinstance(node.func, ast.Name) else None + if name in shared_functions: + return True + return any( + index < len(node.args) and is_shared(node.args[index]) + for index in returned_parameters.get(name or "", set()) + ) + + +def _is_instance_path(node: ast.AST, instance_names: set[str]) -> bool: + while True: + if isinstance(node, ast.Subscript): + node = node.value + elif (resolved := _resolved_attribute(node)) is not None: + node = resolved[0] + else: + break + return isinstance(node, ast.Name) and node.id in instance_names + + +def _static_string(node: ast.AST, names: dict[str, str]) -> str | None: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + if isinstance(node, ast.Name): + return names.get(node.id) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + left = _static_string(node.left, names) + right = _static_string(node.right, names) + return left + right if left is not None and right is not None else None + if isinstance(node, ast.JoinedStr): + parts: list[str] = [] + for value in node.values: + if isinstance(value, ast.Constant) and isinstance(value.value, str): + parts.append(value.value) + elif isinstance(value, ast.FormattedValue): + part = _static_string(value.value, names) + if part is None: + return None + parts.append(part) + else: + return None + return "".join(parts) + return None + + +def _static_string_values(tree: ast.Module) -> dict[str, str]: + assignments: list[tuple[str, ast.AST]] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Assign): + for target in node.targets: + assignments.extend((name, node.value) for name in _target_names(target)) + elif isinstance(node, (ast.AnnAssign, ast.NamedExpr)) and node.value is not None: + assignments.extend((name, node.value) for name in _target_names(node.target)) + + values: dict[str, str] = {} + ambiguous: set[str] = set() + changed = True + while changed: + changed = False + for name, value_node in assignments: + value = _static_string(value_node, values) + if value is None or name in ambiguous: + continue + if name in values and values[name] != value: + values.pop(name) + ambiguous.add(name) + changed = True + elif name not in values: + values[name] = value + changed = True + return values + + +def _dynamic_import_names(tree: ast.Module) -> tuple[set[str], set[str]]: + modules = {"importlib"} + functions: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name == "importlib": + modules.add(alias.asname or "importlib") + elif isinstance(node, ast.ImportFrom) and node.module == "importlib": + for alias in node.names: + if alias.name == "import_module": + functions.add(alias.asname or alias.name) + assignments = [node for node in ast.walk(tree) if isinstance(node, ast.Assign)] + changed = True + while changed: + changed = False + for assignment in assignments: + names = set().union(*(_target_names(target) for target in assignment.targets)) + if isinstance(assignment.value, ast.Name): + if assignment.value.id in modules: + before = len(modules) + modules.update(names) + changed |= len(modules) != before + if assignment.value.id in functions: + before = len(functions) + functions.update(names) + changed |= len(functions) != before + elif ( + isinstance(assignment.value, ast.Attribute) + and assignment.value.attr == "import_module" + and isinstance(assignment.value.value, ast.Name) + and assignment.value.value.id in modules + ): + before = len(functions) + functions.update(names) + changed |= len(functions) != before + return modules, functions + + +def _is_dynamic_import_call( + node: ast.Call, importlib_modules: set[str], import_module_functions: set[str] +) -> bool: + return ( + isinstance(node.func, ast.Name) + and (node.func.id == "__import__" or node.func.id in import_module_functions) + ) or ( + (resolved := _resolved_attribute(node.func)) is not None + and resolved[1] == "import_module" + and isinstance(resolved[0], ast.Name) + and resolved[0].id in importlib_modules + ) + + +def _class_has_mutation_escape(class_node: ast.ClassDef) -> bool: + for statement in class_node.body: + if not isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + positional = [*statement.args.posonlyargs, *statement.args.args] + if not positional: + continue + instance_names = {positional[0].arg} + for node in _function_nodes(statement): + if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign, ast.Delete)): + targets = ( + node.targets + if isinstance(node, (ast.Assign, ast.Delete)) + else [node.target] + ) + if any(_is_instance_path(target, instance_names) for target in targets): + return True + if not isinstance(node, ast.Call): + continue + if ( + isinstance(node.func, ast.Name) + and node.func.id in {"delattr", "setattr"} + and node.args + and _is_instance_path(node.args[0], instance_names) + ): + return True + if ( + (resolved := _resolved_attribute(node.func)) is not None + and resolved[1] in UNBOUND_MUTATOR_METHODS + and node.args + and _is_instance_path(node.args[0], instance_names) + ): + return True + if ( + (resolved := _resolved_attribute(node.func)) is not None + and resolved[1] in MUTATOR_METHODS + and _is_instance_path(resolved[0], instance_names) + ): + return True + return False + + +def _target_names(node: ast.AST) -> set[str]: + if isinstance(node, ast.Name): + return {node.id} + if isinstance(node, (ast.Tuple, ast.List)): + result: set[str] = set() + for element in node.elts: + result.update(_target_names(element)) + return result + return set() + + +def _dict_is_deeply_immutable( + node: ast.Dict, safe_names: set[str], symbols: dict[str, str] +) -> bool: + return all( + key is not None + and _is_immutable_expr(key, safe_names, symbols) + and _is_immutable_expr(value, safe_names, symbols) + for key, value in zip(node.keys, node.values) + ) + + +def _sequence_is_deeply_immutable( + node: ast.List | ast.Set | ast.Tuple, + safe_names: set[str], + symbols: dict[str, str], +) -> bool: + return all(_is_immutable_expr(element, safe_names, symbols) for element in node.elts) + + +def _is_immutable_expr( + node: ast.AST | None, safe_names: set[str], symbols: dict[str, str] +) -> bool: + if node is None or isinstance(node, ast.Constant): + return True + if isinstance(node, ast.Name): + return node.id in safe_names + if isinstance(node, ast.Attribute): + root = _root_name(node) + if root is not None and root in symbols and symbols[root] != f"builtins.{root}": + return node.attr.isupper() + return _is_immutable_expr(node.value, safe_names, symbols) + if isinstance(node, ast.Subscript): + return _symbol(node.value, symbols) in TYPE_SUBSCRIPT_NAMES + if isinstance(node, ast.Tuple): + return _sequence_is_deeply_immutable(node, safe_names, symbols) + if isinstance(node, ast.UnaryOp): + return _is_immutable_expr(node.operand, safe_names, symbols) + if isinstance(node, ast.BinOp): + return _is_immutable_expr(node.left, safe_names, symbols) and _is_immutable_expr( + node.right, safe_names, symbols + ) + if isinstance(node, ast.BoolOp): + return all(_is_immutable_expr(value, safe_names, symbols) for value in node.values) + if isinstance(node, ast.Compare): + return _is_immutable_expr(node.left, safe_names, symbols) and all( + _is_immutable_expr(value, safe_names, symbols) for value in node.comparators + ) + if isinstance(node, ast.IfExp): + return _is_immutable_expr(node.body, safe_names, symbols) and _is_immutable_expr( + node.orelse, safe_names, symbols + ) + if isinstance(node, ast.JoinedStr): + return all( + not isinstance(value, ast.FormattedValue) + or _is_immutable_expr(value.value, safe_names, symbols) + for value in node.values + ) + if isinstance(node, ast.Lambda): + return True + if isinstance(node, ast.Call): + name = _symbol(node.func, symbols) + if name in TYPE_FACTORIES: + return True + local_name = _call_name(node.func) + if local_name is not None and f"frozen-dataclass:{local_name}" in safe_names: + return all( + _is_immutable_expr(arg, safe_names, symbols) for arg in node.args + ) and all( + _is_immutable_expr(keyword.value, safe_names, symbols) + for keyword in node.keywords + ) + if name in IMMUTABLE_SCALAR_CONSTRUCTORS: + return all(_is_immutable_expr(arg, safe_names, symbols) for arg in node.args) + if name in KNOWN_IMMUTABLE_CALLS: + return True + if name in {"builtins.tuple", "builtins.frozenset"}: + if not node.args: + return True + if len(node.args) != 1 or node.keywords: + return False + value = node.args[0] + return isinstance(value, (ast.List, ast.Set, ast.Tuple)) and ( + _sequence_is_deeply_immutable(value, safe_names, symbols) + ) + if name == "types.MappingProxyType": + return ( + len(node.args) == 1 + and not node.keywords + and isinstance(node.args[0], ast.Dict) + and _dict_is_deeply_immutable(node.args[0], safe_names, symbols) + ) + return False + + +def _is_mutable_expr( + node: ast.AST | None, safe_names: set[str], symbols: dict[str, str] +) -> bool: + if node is None: + return False + if isinstance(node, MUTABLE_NODES): + return True + if isinstance(node, ast.Call) and _symbol(node.func, symbols) in MUTABLE_CONSTRUCTORS: + return True + return not _is_immutable_expr(node, safe_names, symbols) + + +def _scope_statements(body: Iterable[ast.stmt]) -> Iterable[ast.stmt]: + for statement in body: + yield statement + nested: list[list[ast.stmt]] = [] + if isinstance(statement, (ast.If, ast.For, ast.AsyncFor, ast.While)): + nested.extend([statement.body, statement.orelse]) + elif isinstance(statement, (ast.With, ast.AsyncWith)): + nested.append(statement.body) + elif isinstance(statement, (ast.Try, ast.TryStar)): + nested.extend([statement.body, statement.orelse, statement.finalbody]) + nested.extend(handler.body for handler in statement.handlers) + elif isinstance(statement, ast.Match): + nested.extend(case.body for case in statement.cases) + for statements in nested: + yield from _scope_statements(statements) + + +def _function_nodes(function: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.AST]: + result: list[ast.AST] = [] + stack: list[ast.AST] = list(reversed(function.body)) + while stack: + node = stack.pop() + result.append(node) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef, ast.Lambda)): + continue + stack.extend(reversed(list(ast.iter_child_nodes(node)))) + return result + + +def _shared_class_nodes( + body: Iterable[ast.stmt], prefix: str = "" +) -> Iterable[tuple[str, ast.ClassDef]]: + for statement in _scope_statements(body): + if not isinstance(statement, ast.ClassDef): + continue + qualified_name = f"{prefix}.{statement.name}" if prefix else statement.name + yield qualified_name, statement + yield from _shared_class_nodes(statement.body, qualified_name) + + +def _is_classvar_annotation(node: ast.AST, symbols: dict[str, str]) -> bool: + value = node.value if isinstance(node, ast.Subscript) else node + return _symbol(value, symbols) == "typing.ClassVar" + + +class _FileChecker: + def __init__( + self, + tree: ast.Module, + path: Path, + _target_classes: set[str], + root: Path | None = None, + ): + self.tree = tree + self.path = path + self.root = (root or path.parent).resolve() + self.violations: list[tuple[int, int, str]] = [] + self.safe_names: set[str] = { + "False", + "None", + "True", + "bool", + "bytes", + "complex", + "float", + "frozenset", + "int", + "range", + "str", + "tuple", + } + self.symbols = {name: f"builtins.{name}" for name in TRUSTED_BUILTINS} + self.import_names: set[str] = set() + self.definition_names: set[str] = set() + self.module_binding_names: set[str] = set() + self.module_mutable_names: set[str] = set() + self.classes: dict[str, ast.ClassDef] = {} + self.shared_classes = list(_shared_class_nodes(tree.body)) + self.functions: dict[str, ast.FunctionDef | ast.AsyncFunctionDef] = {} + self.lambdas: dict[str, ast.Lambda] = {} + self.shared_closure_factories: set[str] = set() + self.shared_returning_functions: set[str] = set() + self.returned_parameters: dict[str, set[int]] = {} + self.stateful_decorator_factories: set[str] = set() + self.shared_instance_attributes: dict[str, set[str]] = {} + + def add(self, node: ast.AST, message: str) -> None: + self.violations.append( + (getattr(node, "lineno", 1), getattr(node, "col_offset", 0) + 1, message) + ) + + def collect_symbols(self) -> None: + shadowed_names: set[str] = set() + for statement in _scope_statements(self.tree.body): + if isinstance(statement, ast.Import): + for alias in statement.names: + name = alias.asname or alias.name.split(".")[0] + self.import_names.add(name) + self.module_binding_names.add(name) + if _candidate_module(self.root, alias.name.split(".")) is None: + self.symbols[name] = alias.name if alias.asname else name + else: + self.symbols.pop(name, None) + elif isinstance(statement, ast.ImportFrom): + local_module = ( + statement.level > 0 + or statement.module is None + or _candidate_module(self.root, statement.module.split(".")) + is not None + ) + for alias in statement.names: + if alias.name == "*": + if statement.module is not None and not local_module: + for export in TRUSTED_STAR_EXPORTS.get( + statement.module, set() + ): + self.import_names.add(export) + self.module_binding_names.add(export) + self.symbols[export] = f"{statement.module}.{export}" + if statement.module == "typing": + self.safe_names.add(export) + else: + name = alias.asname or alias.name + self.import_names.add(name) + self.module_binding_names.add(name) + if statement.module is not None and not local_module: + self.symbols[name] = f"{statement.module}.{alias.name}" + else: + self.symbols.pop(name, None) + if ( + name.isupper() + or statement.module in {"collections.abc", "typing"} + or ( + statement.module == "math" + and alias.name in {"e", "inf", "nan", "pi", "tau"} + ) + ): + self.safe_names.add(name) + elif isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): + self.definition_names.add(statement.name) + self.module_binding_names.add(statement.name) + shadowed_names.add(statement.name) + self.functions[statement.name] = statement + elif isinstance(statement, ast.ClassDef): + self.definition_names.add(statement.name) + self.module_binding_names.add(statement.name) + shadowed_names.add(statement.name) + self.classes[statement.name] = statement + elif isinstance(statement, ast.Assign): + for target in statement.targets: + names = _target_names(target) + self.module_binding_names.update(names) + shadowed_names.update(names) + if isinstance(statement.value, ast.Lambda): + for name in names: + self.lambdas[name] = statement.value + elif isinstance(statement, (ast.AnnAssign, ast.AugAssign)): + names = _target_names(statement.target) + self.module_binding_names.update(names) + shadowed_names.update(names) + for name in shadowed_names: + self.symbols.pop(name, None) + self.safe_names.difference_update(shadowed_names) + self.safe_names.update(self.definition_names) + for _, class_node in self.shared_classes: + frozen = any( + isinstance(decorator, ast.Call) + and _symbol(decorator.func, self.symbols) == "dataclasses.dataclass" + and any( + keyword.arg == "frozen" + and isinstance(keyword.value, ast.Constant) + and keyword.value.value is True + for keyword in decorator.keywords + ) + for decorator in class_node.decorator_list + ) + immutable_defaults = all( + not isinstance(statement, ast.AnnAssign) + or statement.value is None + or _is_immutable_expr(statement.value, self.safe_names, self.symbols) + for statement in class_node.body + ) + if frozen and immutable_defaults and not _class_has_mutation_escape(class_node): + self.safe_names.add(f"frozen-dataclass:{class_node.name}") + + def mark_shared_closure_factories(self) -> None: + def consider(value: ast.AST) -> None: + if not isinstance(value, ast.Call): + return + name = _call_name(value.func) + function = self.functions.get(name or "") + if function is not None and any( + isinstance(node, ast.Nonlocal) for node in ast.walk(function) + ): + self.shared_closure_factories.add(function.name) + + scopes = [self.tree.body] + scopes.extend(class_node.body for class_node in self.classes.values()) + for body in scopes: + for statement in _scope_statements(body): + if isinstance(statement, ast.Assign): + consider(statement.value) + elif isinstance(statement, ast.AnnAssign) and statement.value is not None: + consider(statement.value) + + for node in ast.walk(self.tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + for decorator in node.decorator_list: + name = _call_name(decorator.func if isinstance(decorator, ast.Call) else decorator) + function = self.functions.get(name or "") + if function is not None and any( + isinstance(child, ast.Nonlocal) for child in ast.walk(function) + ): + self.shared_closure_factories.add(function.name) + + def collect_function_summaries(self) -> None: + """Summarize simple wrappers without executing student code.""" + + callables: dict[ + str, ast.FunctionDef | ast.AsyncFunctionDef | ast.Lambda + ] = {**self.functions, **self.lambdas} + changed = True + while changed: + changed = False + for function_name, function in callables.items(): + positional = [*function.args.posonlyargs, *function.args.args] + local_names = { + argument.arg + for argument in [*positional, *function.args.kwonlyargs] + } + if function.args.vararg is not None: + local_names.add(function.args.vararg.arg) + if function.args.kwarg is not None: + local_names.add(function.args.kwarg.arg) + + if isinstance(function, ast.Lambda): + nodes = [function.body] + return_values = [function.body] + else: + nodes = _function_nodes(function) + return_values = [ + node.value + for node in nodes + if isinstance(node, ast.Return) and node.value is not None + ] + assignments = [ + node + for node in nodes + if _assignment_parts(node) is not None + ] + for assignment in assignments: + _, targets = _assignment_parts(assignment) or (assignment, []) + for target in targets: + local_names.update(_target_names(target)) + + shared_aliases: set[str] = set() + parameter_aliases = { + argument.arg: {index} for index, argument in enumerate(positional) + } + + def returned_parameter_indexes(node: ast.AST) -> set[int]: + if isinstance(node, ast.Name): + return parameter_aliases.get(node.id, set()) + resolved = _resolved_attribute(node) + if resolved is not None: + return returned_parameter_indexes(resolved[0]) + if isinstance(node, ast.Subscript): + return returned_parameter_indexes(node.value) + if isinstance(node, ast.Call): + name = node.func.id if isinstance(node.func, ast.Name) else None + return set().union( + *( + returned_parameter_indexes(node.args[index]) + for index in self.returned_parameters.get( + name or "", set() + ) + if index < len(node.args) + ), + set(), + ) + return set() + + def returns_shared(node: ast.AST) -> bool: + if _is_immutable_expr(node, self.safe_names, self.symbols): + return False + if isinstance(node, ast.Name): + return ( + node.id in self.module_binding_names + and node.id not in local_names + ) or node.id in shared_aliases + resolved = _resolved_attribute(node) + if resolved is not None: + return returns_shared(resolved[0]) + if isinstance(node, ast.Subscript): + return returns_shared(node.value) + return _call_returns_shared( + node, + returns_shared, + self.shared_returning_functions, + self.returned_parameters, + ) or ( + isinstance(node, ast.Call) + and _symbol(node.func, self.symbols) + == "importlib.import_module" + ) + + aliases_changed = True + while aliases_changed: + aliases_changed = False + for assignment in assignments: + value, targets = _assignment_parts(assignment) or (assignment, []) + indexes = returned_parameter_indexes(value) + shared = returns_shared(value) + before = ( + len(shared_aliases), + sum(map(len, parameter_aliases.values())), + ) + for target in targets: + for name in _target_names(target): + if shared: + shared_aliases.add(name) + if indexes: + parameter_aliases.setdefault(name, set()).update(indexes) + aliases_changed |= before != ( + len(shared_aliases), + sum(map(len, parameter_aliases.values())), + ) + + indexes = set().union( + *(returned_parameter_indexes(value) for value in return_values), set() + ) + known_indexes = self.returned_parameters.setdefault(function_name, set()) + before = len(known_indexes) + known_indexes.update(indexes) + changed |= len(known_indexes) != before + if any(returns_shared(value) for value in return_values): + if function_name not in self.shared_returning_functions: + self.shared_returning_functions.add(function_name) + changed = True + for value in return_values: + if isinstance(value, ast.Call): + called = _symbol(value.func, self.symbols) + local_called = ( + value.func.id + if isinstance(value.func, ast.Name) + else None + ) + if ( + called in STATEFUL_DECORATORS + or local_called in self.stateful_decorator_factories + ) and function_name not in self.stateful_decorator_factories: + self.stateful_decorator_factories.add(function_name) + changed = True + + def decorator_is_stateful( + self, decorator: ast.AST, symbols: dict[str, str] + ) -> bool: + expression = decorator.func if isinstance(decorator, ast.Call) else decorator + return _symbol(expression, symbols) in STATEFUL_DECORATORS or ( + isinstance(expression, ast.Name) + and expression.id in self.stateful_decorator_factories + ) + + def check_module_bindings(self) -> None: + for statement in _scope_statements(self.tree.body): + value: ast.AST | None = None + targets: set[str] = set() + if isinstance(statement, ast.Assign): + value = statement.value + for target in statement.targets: + targets.update(_target_names(target)) + elif isinstance(statement, ast.AnnAssign): + value = statement.value + targets.update(_target_names(statement.target)) + elif isinstance(statement, ast.AugAssign): + value = statement.value + targets.update(_target_names(statement.target)) + else: + continue + + if value is None: + continue + if _is_immutable_expr(value, self.safe_names, self.symbols): + self.safe_names.update(targets) + continue + for name in sorted(targets - ALLOWED_MUTABLE_METADATA): + self.module_mutable_names.add(name) + self.add( + statement, + f"module-level value '{name}' is mutable or stateful and can be shared between processes", + ) + + def check_class_bindings(self) -> None: + for class_name, class_node in self.shared_classes: + class_safe_names = set(self.safe_names) + is_dataclass = any( + _symbol( + decorator.func if isinstance(decorator, ast.Call) else decorator, + self.symbols, + ) + == "dataclasses.dataclass" + for decorator in class_node.decorator_list + ) + is_pydantic_model = any( + ( + isinstance(base, ast.Name) + and _symbol(base, self.symbols) + in {"pydantic.BaseModel", "pydantic.main.BaseModel"} + ) + or ( + isinstance(base, ast.Attribute) + and _symbol(base, self.symbols) + in {"pydantic.BaseModel", "pydantic.main.BaseModel"} + ) + for base in class_node.bases + ) + is_enum = any( + ( + isinstance(base, ast.Name) + and _symbol(base, self.symbols) in ENUM_BASE_NAMES + ) + or ( + isinstance(base, ast.Attribute) + and _symbol(base, self.symbols) in ENUM_BASE_NAMES + ) + for base in class_node.bases + ) + class_symbols = dict(self.symbols) + class_import_symbols: dict[str, str] = {} + class_shadowed_names: set[str] = set() + for class_statement in _scope_statements(class_node.body): + names: set[str] = set() + if isinstance( + class_statement, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef) + ): + names.add(class_statement.name) + elif isinstance(class_statement, ast.Assign): + for target in class_statement.targets: + names.update(_target_names(target)) + elif isinstance(class_statement, (ast.AnnAssign, ast.AugAssign)): + names.update(_target_names(class_statement.target)) + elif isinstance(class_statement, ast.Import): + for alias in class_statement.names: + name = alias.asname or alias.name.split(".")[0] + if _candidate_module(self.root, alias.name.split(".")) is None: + class_import_symbols[name] = ( + alias.name if alias.asname else name + ) + elif isinstance(class_statement, ast.ImportFrom): + local_module = ( + class_statement.level > 0 + or class_statement.module is None + or _candidate_module( + self.root, class_statement.module.split(".") + ) + is not None + ) + if class_statement.module is not None and not local_module: + for alias in class_statement.names: + if alias.name == "*": + for export in TRUSTED_STAR_EXPORTS.get( + class_statement.module, set() + ): + class_import_symbols[export] = ( + f"{class_statement.module}.{export}" + ) + else: + name = alias.asname or alias.name + class_import_symbols[name] = ( + f"{class_statement.module}.{alias.name}" + ) + class_shadowed_names.update(names) + class_symbols.update( + (name, symbol) + for name, symbol in class_import_symbols.items() + if name not in class_shadowed_names + ) + for name in class_shadowed_names: + class_symbols.pop(name, None) + class_safe_names.discard(name) + for class_statement in class_node.body: + if not isinstance( + class_statement, (ast.FunctionDef, ast.AsyncFunctionDef) + ): + continue + for decorator in class_statement.decorator_list: + expression = decorator.func if isinstance(decorator, ast.Call) else decorator + name = _symbol(expression, class_symbols) + if self.decorator_is_stateful(decorator, class_symbols): + self.add( + decorator, + f"'{(name or _call_name(expression) or 'decorator').rsplit('.', 1)[-1]}' keeps a cache shared between process instances", + ) + for statement in _scope_statements(class_node.body): + def targets_class_namespace(target: ast.AST) -> bool: + if isinstance(target, ast.Subscript): + return ( + isinstance(target.value, ast.Call) + and isinstance(target.value.func, ast.Name) + and target.value.func.id in {"globals", "locals"} + ) + if isinstance(target, (ast.Tuple, ast.List)): + return any(targets_class_namespace(item) for item in target.elts) + return False + + if isinstance(statement, ast.Assign) and any( + targets_class_namespace(target) for target in statement.targets + ): + self.add(statement, "assignment mutates class-level shared state") + elif isinstance(statement, (ast.AnnAssign, ast.AugAssign)) and ( + targets_class_namespace(statement.target) + ): + self.add(statement, "assignment mutates class-level shared state") + elif isinstance(statement, ast.Delete) and any( + targets_class_namespace(target) for target in statement.targets + ): + self.add(statement, "deletion mutates class-level shared state") + elif isinstance(statement, ast.Expr) and isinstance( + statement.value, ast.Call + ): + call = statement.value + if ( + isinstance(call.func, ast.Attribute) + and call.func.attr in MUTATOR_METHODS | {"__setitem__"} + and isinstance(call.func.value, ast.Call) + and isinstance(call.func.value.func, ast.Name) + and call.func.value.func.id in {"globals", "locals"} + ): + self.add(call, "call mutates class-level shared state") + + value: ast.AST | None = None + targets: set[str] = set() + if isinstance(statement, ast.Assign): + value = statement.value + for target in statement.targets: + targets.update(_target_names(target)) + elif isinstance(statement, ast.AnnAssign): + value = statement.value + targets.update(_target_names(statement.target)) + else: + continue + if value is None: + continue + if _is_immutable_expr(value, class_safe_names, class_symbols): + class_safe_names.update(targets) + for name in targets: + class_symbols.pop(name, None) + continue + if ( + is_enum + and isinstance(value, ast.Call) + and _symbol(value.func, class_symbols) == "enum.auto" + and not value.args + and not value.keywords + ): + class_safe_names.update(targets) + continue + if ( + is_dataclass + and isinstance(statement, ast.AnnAssign) + and not _is_classvar_annotation(statement.annotation, class_symbols) + and isinstance(value, ast.Call) + and _symbol(value.func, class_symbols) == "dataclasses.field" + and not value.args + and all( + keyword.arg != "default" + or not _is_mutable_expr( + keyword.value, class_safe_names, class_symbols + ) + for keyword in value.keywords + ) + ): + continue + if ( + is_pydantic_model + and isinstance(statement, ast.AnnAssign) + and not _is_classvar_annotation(statement.annotation, class_symbols) + and isinstance(value, ast.Call) + and _symbol(value.func, class_symbols) == "pydantic.Field" + and not value.args + and all( + keyword.arg != "default" + or not _is_mutable_expr( + keyword.value, class_safe_names, class_symbols + ) + for keyword in value.keywords + ) + ): + continue + for name in sorted(targets): + self.add( + statement, + f"class attribute '{class_name}.{name}' is mutable or stateful and is shared by all process instances", + ) + + def check_module_mutations(self) -> None: + statements = list(_scope_statements(self.tree.body)) + class_aliases = set(self.classes) + shared_aliases: set[str] = set() + global_objects = self.module_binding_names + + def is_class_ref(node: ast.AST) -> bool: + return isinstance(node, ast.Name) and node.id in class_aliases + + def is_shared_ref(node: ast.AST) -> bool: + if isinstance(node, ast.Name): + return node.id in global_objects or node.id in shared_aliases + if isinstance(node, ast.Attribute): + return ( + is_class_ref(node.value) + or is_shared_ref(node.value) + or ( + isinstance(node.value, ast.Name) + and node.value.id in global_objects + ) + ) + if isinstance(node, ast.Subscript): + return is_shared_ref(node.value) + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "getattr" + and node.args + ): + return is_class_ref(node.args[0]) or is_shared_ref(node.args[0]) + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "eval" + and node.args + and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str) + ): + try: + expression = ast.parse(node.args[0].value, mode="eval").body + except SyntaxError: + return False + return is_shared_ref(expression) + return ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "vars" + and len(node.args) == 1 + and (is_class_ref(node.args[0]) or is_shared_ref(node.args[0])) + ) or ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id in {"globals", "locals"} + ) + + assignments = [ + statement + for statement in statements + if isinstance(statement, (ast.Assign, ast.AnnAssign, ast.NamedExpr)) + ] + changed = True + while changed: + changed = False + for assignment in assignments: + value, targets = _assignment_parts(assignment) or (assignment, []) + names = set().union(*map(_target_names, targets), set()) + before = (len(class_aliases), len(shared_aliases)) + if is_class_ref(value): + class_aliases.update(names) + if is_shared_ref(value): + shared_aliases.update(names) + if before != (len(class_aliases), len(shared_aliases)): + changed = True + + def target_is_shared(target: ast.AST) -> bool: + if isinstance(target, ast.Attribute): + return ( + is_class_ref(target.value) + or is_shared_ref(target.value) + or ( + isinstance(target.value, ast.Name) + and target.value.id in global_objects + ) + ) + if isinstance(target, ast.Subscript): + return is_shared_ref(target.value) + if isinstance(target, (ast.Tuple, ast.List)): + return any(target_is_shared(element) for element in target.elts) + return False + + for statement in statements: + if isinstance(statement, ast.Assign): + if any(target_is_shared(target) for target in statement.targets): + self.add(statement, "assignment creates or mutates module-level shared state") + elif isinstance(statement, (ast.AnnAssign, ast.AugAssign)): + if target_is_shared(statement.target): + self.add(statement, "assignment creates or mutates module-level shared state") + elif isinstance(statement, ast.Delete): + if any(target_is_shared(target) for target in statement.targets): + self.add(statement, "deletion mutates module-level shared state") + elif isinstance(statement, ast.Expr) and isinstance(statement.value, ast.Call): + call = statement.value + if isinstance(call.func, ast.Name) and call.func.id in { + "exec", + "globals", + "locals", + }: + self.add(call, f"'{call.func.id}' can access or modify module-level shared state") + elif ( + isinstance(call.func, ast.Name) + and call.func.id == "eval" + and is_shared_ref(call) + ): + self.add(call, "'eval' accesses module-level shared state") + elif ( + isinstance(call.func, ast.Name) + and call.func.id in {"setattr", "delattr"} + and call.args + and ( + is_class_ref(call.args[0]) + or is_shared_ref(call.args[0]) + or ( + isinstance(call.args[0], ast.Name) + and call.args[0].id in global_objects + ) + ) + ): + self.add(call, f"'{call.func.id}' mutates module-level shared state") + elif ( + _symbol(call.func, self.symbols) in OPERATOR_MUTATORS + and call.args + and is_shared_ref(call.args[0]) + ): + self.add(call, "operator call mutates module-level shared state") + elif ( + (resolved := _resolved_attribute(call.func)) is not None + and resolved[1] in UNBOUND_MUTATOR_METHODS + and call.args + and ( + is_class_ref(call.args[0]) + or is_shared_ref(call.args[0]) + or ( + isinstance(call.args[0], ast.Name) + and call.args[0].id in global_objects + ) + ) + ): + self.add(call, f"'{resolved[1]}' mutates module-level shared state") + elif ( + (resolved := _resolved_attribute(call.func)) is not None + and resolved[1] in MUTATOR_METHODS + and is_shared_ref(resolved[0]) + ): + if not ( + _symbol(resolved[0], self.symbols) == "sys.path" + and resolved[1] in {"append", "insert"} + ): + self.add( + call, + f"'{resolved[1]}' mutates module-level shared state", + ) + elif ( + (canonical := _symbol(call.func, self.symbols)) is not None + and (_root_name(call.func) or "") in self.import_names + and canonical not in ALLOWED_IMPORTED_CALLS + and canonical not in STATEFUL_DECORATORS + and canonical not in TYPE_FACTORIES + and canonical not in MUTABLE_CONSTRUCTORS + and canonical not in IMMUTABLE_SCALAR_CONSTRUCTORS + ): + self.add( + call, + f"call to imported API '{canonical}' is not known to be free of shared module-state mutation", + ) + + def check_defaults(self) -> None: + for node in ast.walk(self.tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + continue + defaults = list(node.args.defaults) + defaults.extend(default for default in node.args.kw_defaults if default is not None) + for default in defaults: + if _is_mutable_expr(default, self.safe_names, self.symbols): + self.add(default, "mutable default argument is shared between function calls") + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + for decorator in node.decorator_list: + expression = decorator.func if isinstance(decorator, ast.Call) else decorator + name = _symbol(expression, self.symbols) + if self.decorator_is_stateful(decorator, self.symbols): + self.add( + decorator, + f"'{(name or _call_name(expression) or 'decorator').rsplit('.', 1)[-1]}' keeps a cache shared between process instances", + ) + + def check_dynamic_imports(self) -> None: + string_values = _static_string_values(self.tree) + importlib_modules, import_module_functions = _dynamic_import_names(self.tree) + for node in ast.walk(self.tree): + if ( + isinstance(node, ast.Call) + and node.args + and _is_dynamic_import_call( + node, importlib_modules, import_module_functions + ) + and _static_string(node.args[0], string_values) is None + ): + self.add( + node, + "dynamic import name cannot be resolved statically; shared state in the imported module cannot be checked", + ) + + def collect_shared_instance_attributes(self) -> None: + for class_name, class_node in self.classes.items(): + attributes = self.shared_instance_attributes.setdefault(class_name, set()) + functions = [ + statement + for statement in class_node.body + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)) + ] + changed = True + while changed: + changed = False + for function in functions: + positional = [*function.args.posonlyargs, *function.args.args] + if not positional: + continue + instance_names = {positional[0].arg} + nodes = _function_nodes(function) + assignments = [ + node + for node in nodes + if isinstance(node, (ast.Assign, ast.AnnAssign, ast.NamedExpr)) + ] + local_names = { + argument.arg + for argument in [*positional, *function.args.kwonlyargs] + } + local_import_names: set[str] = set() + for node in nodes: + if isinstance(node, ast.Assign): + for target in node.targets: + local_names.update(_target_names(target)) + elif isinstance(node, (ast.AnnAssign, ast.NamedExpr)): + local_names.update(_target_names(node.target)) + elif isinstance(node, (ast.Import, ast.ImportFrom)): + names = { + alias.asname or alias.name.split(".")[0] + for alias in node.names + if alias.name != "*" + } + local_names.update(names) + local_import_names.update(names) + + shared_aliases: set[str] = set() + + def is_instance(node: ast.AST) -> bool: + return isinstance(node, ast.Name) and node.id in instance_names + + def is_shared(node: ast.AST) -> bool: + if isinstance(node, ast.Name): + return ( + node.id in self.module_binding_names + and node.id not in local_names + ) or node.id in shared_aliases | local_import_names + if (resolved := _resolved_attribute(node)) is not None: + return ( + is_instance(resolved[0]) and resolved[1] in attributes + ) or is_shared(resolved[0]) + if isinstance(node, ast.Subscript): + return is_shared(node.value) + return _call_returns_shared( + node, + is_shared, + self.shared_returning_functions, + self.returned_parameters, + ) or ( + isinstance(node, ast.Call) + and _symbol(node.func, self.symbols) + == "importlib.import_module" + ) + + aliases_changed = True + while aliases_changed: + aliases_changed = False + for assignment in assignments: + value, targets = _assignment_parts(assignment) or (assignment, []) + if is_instance(value): + names = set().union(*(_target_names(t) for t in targets)) + before = len(instance_names) + instance_names.update(names) + aliases_changed |= len(instance_names) != before + if not is_shared(value): + continue + for target in targets: + names = _target_names(target) + before = len(shared_aliases) + shared_aliases.update(names) + aliases_changed |= len(shared_aliases) != before + if ( + isinstance(target, ast.Attribute) + and is_instance(target.value) + and target.attr not in attributes + ): + attributes.add(target.attr) + changed = True + + def check_function( + self, + function: ast.FunctionDef | ast.AsyncFunctionDef, + owner: str | None, + shared_closure: bool, + ) -> None: + nodes = _function_nodes(function) + all_class_names = set(self.classes) + instance_aliases: set[str] = set() + class_aliases: set[str] = set() + shared_aliases: set[str] = set() + shared_instance_attributes = self.shared_instance_attributes.get(owner or "", set()) + positional = [*function.args.posonlyargs, *function.args.args] + local_names = { + argument.arg + for argument in [*positional, *function.args.kwonlyargs] + } + if function.args.vararg is not None: + local_names.add(function.args.vararg.arg) + if function.args.kwarg is not None: + local_names.add(function.args.kwarg.arg) + local_import_names: set[str] = set() + local_import_symbols: dict[str, str] = {} + for node in nodes: + if isinstance(node, ast.Assign): + for target in node.targets: + local_names.update(_target_names(target)) + elif isinstance(node, (ast.AnnAssign, ast.AugAssign, ast.NamedExpr)): + local_names.update(_target_names(node.target)) + elif isinstance(node, ast.Import): + names = { + alias.asname or alias.name.split(".")[0] + for alias in node.names + } + local_names.update(names) + local_import_names.update(names) + for alias in node.names: + name = alias.asname or alias.name.split(".")[0] + if _candidate_module(self.root, alias.name.split(".")) is None: + local_import_symbols[name] = alias.name if alias.asname else name + elif isinstance(node, ast.ImportFrom): + names = { + alias.asname or alias.name + for alias in node.names + if alias.name != "*" + } + local_names.update(names) + local_import_names.update(names) + local_module = ( + node.level > 0 + or node.module is None + or _candidate_module(self.root, node.module.split(".")) is not None + ) + if node.module is not None and not local_module: + for alias in node.names: + if alias.name != "*": + local_import_symbols[alias.asname or alias.name] = ( + f"{node.module}.{alias.name}" + ) + function_symbols = dict(self.symbols) + for name in local_names - local_import_names: + function_symbols.pop(name, None) + function_symbols.update(local_import_symbols) + imported_callable_names = (self.import_names - local_names) | local_import_names + if owner is not None and positional: + first = positional[0].arg + if first == "cls" or any( + isinstance(decorator, ast.Name) and decorator.id == "classmethod" + for decorator in function.decorator_list + ): + class_aliases.add(first) + else: + instance_aliases.add(first) + + def is_instance_ref(node: ast.AST) -> bool: + return isinstance(node, ast.Name) and node.id in instance_aliases + + def is_class_ref(node: ast.AST) -> bool: + if isinstance(node, ast.Name): + return ( + node.id in all_class_names and node.id not in local_names + ) or node.id in class_aliases + if isinstance(node, ast.Attribute): + return node.attr == "__class__" and is_instance_ref(node.value) + return ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "type" + and len(node.args) == 1 + and is_instance_ref(node.args[0]) + ) + + global_objects = self.module_binding_names + + def is_shared_ref(node: ast.AST) -> bool: + if isinstance(node, ast.Name): + return ( + node.id in global_objects and node.id not in local_names + ) or node.id in shared_aliases | local_import_names + if (resolved := _resolved_attribute(node)) is not None: + value, attribute = resolved + if is_instance_ref(value) and attribute in shared_instance_attributes: + return True + if attribute == "__dict__" and ( + is_class_ref(value) + or (isinstance(value, ast.Name) and value.id in global_objects) + ): + return True + return is_class_ref(value) or is_shared_ref(value) or ( + isinstance(value, ast.Name) and value.id in global_objects + ) + if isinstance(node, ast.Subscript): + return is_shared_ref(node.value) + if _call_returns_shared( + node, + is_shared_ref, + self.shared_returning_functions, + self.returned_parameters, + ): + return True + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "getattr" + and node.args + ): + return ( + is_class_ref(node.args[0]) + or is_shared_ref(node.args[0]) + or ( + isinstance(node.args[0], ast.Name) + and node.args[0].id in global_objects + ) + ) + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "eval" + and node.args + and isinstance(node.args[0], ast.Constant) + and isinstance(node.args[0].value, str) + ): + try: + expression = ast.parse(node.args[0].value, mode="eval").body + except SyntaxError: + return False + return is_shared_ref(expression) + return ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "vars" + and len(node.args) == 1 + and ( + is_class_ref(node.args[0]) + or is_shared_ref(node.args[0]) + or ( + isinstance(node.args[0], ast.Name) + and node.args[0].id in global_objects + ) + ) + ) or ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "globals" + ) + + assignments = [ + node + for node in nodes + if isinstance(node, (ast.Assign, ast.AnnAssign, ast.NamedExpr)) + ] + unbound_mutator_aliases: set[str] = set() + bound_mutator_aliases: set[str] = set() + imported_callable_aliases: dict[str, str] = {} + changed = True + while changed: + changed = False + for assignment in assignments: + value, targets = _assignment_parts(assignment) or (assignment, []) + names: set[str] = set() + for target in targets: + names.update(_target_names(target)) + before = ( + len(instance_aliases), + len(class_aliases), + len(shared_aliases), + len(unbound_mutator_aliases), + len(bound_mutator_aliases), + len(imported_callable_aliases), + ) + if is_instance_ref(value): + instance_aliases.update(names) + if is_class_ref(value): + class_aliases.update(names) + if is_shared_ref(value): + shared_aliases.update(names) + symbol = _symbol(value, function_symbols) + aliased_symbol = ( + imported_callable_aliases.get(value.id) + if isinstance(value, ast.Name) + else None + ) + if aliased_symbol is not None: + symbol = aliased_symbol + if ( + symbol is not None + and ( + aliased_symbol is not None + or (_root_name(value) or "") in imported_callable_names + ) + ): + for name in names: + imported_callable_aliases[name] = symbol + if symbol in OPERATOR_MUTATORS or ( + isinstance(value, ast.Name) + and value.id in unbound_mutator_aliases + ): + unbound_mutator_aliases.update(names) + if ( + (resolved := _resolved_attribute(value)) is not None + and resolved[1] in MUTATOR_METHODS + and is_shared_ref(resolved[0]) + ) or ( + isinstance(value, ast.Name) and value.id in bound_mutator_aliases + ): + bound_mutator_aliases.update(names) + if before != ( + len(instance_aliases), + len(class_aliases), + len(shared_aliases), + len(unbound_mutator_aliases), + len(bound_mutator_aliases), + len(imported_callable_aliases), + ): + changed = True + + def target_is_shared(target: ast.AST) -> bool: + if isinstance(target, ast.Attribute): + return ( + is_class_ref(target.value) + or is_shared_ref(target.value) + or ( + isinstance(target.value, ast.Name) + and target.value.id in global_objects + ) + ) + if isinstance(target, ast.Subscript): + return is_shared_ref(target.value) + if isinstance(target, (ast.Tuple, ast.List)): + return any(target_is_shared(element) for element in target.elts) + return False + + for node in nodes: + if isinstance(node, ast.Global): + self.add(node, "'global' state can be shared between process instances") + elif isinstance(node, ast.Nonlocal) and shared_closure: + self.add(node, "shared closure state can be accessed by multiple process instances") + elif isinstance(node, ast.Assign): + if any(target_is_shared(target) for target in node.targets): + self.add(node, "assignment mutates state outside the process instance") + elif isinstance(node, (ast.AnnAssign, ast.AugAssign)): + if target_is_shared(node.target): + self.add(node, "assignment mutates state outside the process instance") + elif isinstance(node, ast.Delete): + if any(target_is_shared(target) for target in node.targets): + self.add(node, "deletion mutates state outside the process instance") + elif isinstance(node, ast.Call): + if isinstance(node.func, ast.Name) and node.func.id in {"exec", "globals"}: + self.add(node, f"'{node.func.id}' can access or modify module-level shared state") + elif ( + isinstance(node.func, ast.Name) + and node.func.id == "eval" + and is_shared_ref(node) + ): + self.add(node, "'eval' accesses module-level shared state") + elif ( + isinstance(node.func, ast.Name) + and node.func.id in {"setattr", "delattr"} + and node.args + and ( + is_class_ref(node.args[0]) + or is_shared_ref(node.args[0]) + or ( + isinstance(node.args[0], ast.Name) + and node.args[0].id in global_objects + ) + ) + ): + self.add(node, f"'{node.func.id}' mutates shared object state") + elif ( + ( + _symbol(node.func, function_symbols) in OPERATOR_MUTATORS + or ( + isinstance(node.func, ast.Name) + and node.func.id in unbound_mutator_aliases + ) + ) + and node.args + and is_shared_ref(node.args[0]) + ): + self.add(node, "operator call mutates shared object state") + elif ( + isinstance(node.func, ast.Name) + and node.func.id in bound_mutator_aliases + ): + self.add(node, "aliased method mutates shared object state") + elif ( + ( + imported_callable_aliases.get( + node.func.id, _symbol(node.func, function_symbols) + ) + if isinstance(node.func, ast.Name) + else _symbol(node.func, function_symbols) + ) + in IMPORTED_ARGUMENT_MUTATORS + and node.args + and is_shared_ref(node.args[0]) + ): + self.add(node, "imported function mutates shared object state") + elif ( + (resolved := _resolved_attribute(node.func)) is not None + and resolved[1] in UNBOUND_MUTATOR_METHODS + and node.args + and ( + is_class_ref(node.args[0]) + or is_shared_ref(node.args[0]) + or ( + isinstance(node.args[0], ast.Name) + and node.args[0].id in global_objects + ) + ) + ): + self.add(node, f"'{resolved[1]}' mutates shared object state") + elif ( + (resolved := _resolved_attribute(node.func)) is not None + and resolved[1] in MUTATOR_METHODS + and is_shared_ref(resolved[0]) + ): + self.add(node, f"'{resolved[1]}' mutates state outside the process instance") + elif ( + ( + canonical := ( + imported_callable_aliases.get( + node.func.id, _symbol(node.func, function_symbols) + ) + if isinstance(node.func, ast.Name) + else _symbol(node.func, function_symbols) + ) + ) + is not None + and ( + (_root_name(node.func) or "") in imported_callable_names + or ( + isinstance(node.func, ast.Name) + and node.func.id in imported_callable_aliases + ) + ) + and canonical not in ALLOWED_IMPORTED_CALLS + and canonical not in STATEFUL_DECORATORS + and canonical not in TYPE_FACTORIES + and canonical not in MUTABLE_CONSTRUCTORS + and canonical not in IMMUTABLE_SCALAR_CONSTRUCTORS + ): + self.add( + node, + f"call to imported API '{canonical}' is not known to be free of shared module-state mutation", + ) + + def check_functions(self) -> None: + def visit_body( + body: Iterable[ast.stmt], + owner: str | None = None, + shared_closure: bool = False, + ) -> None: + for statement in body: + if isinstance(statement, (ast.FunctionDef, ast.AsyncFunctionDef)): + function_closure = shared_closure or ( + owner is None and statement.name in self.shared_closure_factories + ) + self.check_function(statement, owner, function_closure) + visit_body(statement.body, None, function_closure) + elif isinstance(statement, ast.ClassDef): + visit_body(statement.body, statement.name, shared_closure) + else: + for child in ast.iter_child_nodes(statement): + if isinstance(child, ast.stmt): + visit_body([child], owner, shared_closure) + + visit_body(self.tree.body) + + def run(self) -> list[tuple[int, int, str]]: + self.collect_symbols() + self.mark_shared_closure_factories() + self.check_module_bindings() + self.collect_function_summaries() + self.check_class_bindings() + self.check_module_mutations() + self.check_defaults() + self.check_dynamic_imports() + self.collect_shared_instance_attributes() + self.check_functions() + return sorted(set(self.violations)) + + +def _candidate_module(root: Path, parts: list[str]) -> Path | None: + if not parts: + return None + module = root.joinpath(*parts) + candidates = [module.with_suffix(".py"), module / "__init__.py"] + root = root.resolve() + for candidate in candidates: + try: + resolved = candidate.resolve() + resolved.relative_to(root) + except (OSError, ValueError): + continue + if resolved.is_file(): + return resolved + return None + + +def _local_imports(tree: ast.Module, current: Path, root: Path) -> set[Path]: + result: set[Path] = set() + importlib_modules, import_module_functions = _dynamic_import_names(tree) + string_values = _static_string_values(tree) + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + candidate = _candidate_module(root, alias.name.split(".")) + if candidate is not None: + result.add(candidate) + elif isinstance(node, ast.ImportFrom): + if node.level: + base = current.parent + for _ in range(node.level - 1): + base = base.parent + else: + base = root + module_parts = node.module.split(".") if node.module else [] + candidate = _candidate_module(base, module_parts) + if candidate is not None: + result.add(candidate) + for alias in node.names: + if alias.name == "*": + continue + candidate = _candidate_module(base, [*module_parts, alias.name]) + if candidate is not None: + result.add(candidate) + elif isinstance(node, ast.Call) and node.args: + module_name = _static_string(node.args[0], string_values) + if ( + _is_dynamic_import_call( + node, importlib_modules, import_module_functions + ) + and module_name is not None + and not module_name.startswith(".") + ): + candidate = _candidate_module(root, module_name.split(".")) + if candidate is not None: + result.add(candidate) + return result + + +def validate_source(source: str, filename: str, class_names: list[str]) -> list[str]: + tree = ast.parse(source, filename=filename) + path = Path(filename) + return [ + f"{filename}:{line}:{column}: {message}" + for line, column, message in _FileChecker( + tree, path, set(class_names), path.parent + ).run() + ] + + +def validate_solution(path: str, class_names: list[str]) -> list[str]: + entrypoint = Path(path).resolve() + root = entrypoint.parent + pending = [entrypoint] + visited: set[Path] = set() + violations: list[str] = [] + while pending: + current = pending.pop() + if current in visited: + continue + visited.add(current) + source = current.read_text(encoding="utf-8") + tree = ast.parse(source, filename=str(current)) + checker = _FileChecker( + tree, + current, + set(class_names) if current == entrypoint else set(), + root, + ) + violations.extend( + f"{current}:{line}:{column}: {message}" + for line, column, message in checker.run() + ) + pending.extend(sorted(_local_imports(tree, current, root) - visited)) + return sorted(set(violations)) diff --git a/homework/01-guarantees/tests/src/common.rs b/homework/01-guarantees/tests/src/common.rs new file mode 100644 index 0000000..aad24e6 --- /dev/null +++ b/homework/01-guarantees/tests/src/common.rs @@ -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 { + 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 { + 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, + expected_tip: &String, +) -> Result, 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, + expected_msg_count: &HashMap, +) -> 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, + expected_msg_count: &HashMap, +) -> 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) +} diff --git a/homework/01-guarantees/tests/src/main.rs b/homework/01-guarantees/tests/src/main.rs new file mode 100644 index 0000000..695e89b --- /dev/null +++ b/homework/01-guarantees/tests/src/main.rs @@ -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, + + /// Print execution trace + #[clap(long, short)] + debug: bool, + + /// Guarantee to check + #[clap(long, short, possible_values = ["AMO", "ALO", "EO", "EOO"])] + guarantee: Option, + + /// 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) -> 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()) +} diff --git a/homework/01-guarantees/tests/src/shared_state.rs b/homework/01-guarantees/tests/src/shared_state.rs new file mode 100644 index 0000000..77c5b99 --- /dev/null +++ b/homework/01-guarantees/tests/src/shared_state.rs @@ -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> { + 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, 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::>()) + .map_err(|error| error.to_string()) + }) +} + +fn validate_solution(path: &str, class_names: &[&str]) -> Result, 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::>()) + .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 { + 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::::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__"))); + } +} diff --git a/homework/01-guarantees/tests/src/tests.rs b/homework/01-guarantees/tests/src/tests.rs new file mode 100644 index 0000000..d46822e --- /dev/null +++ b/homework/01-guarantees/tests/src/tests.rs @@ -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 ( 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) +} diff --git a/homework/01-guarantees/tests/src/tests_mc.rs b/homework/01-guarantees/tests/src/tests_mc.rs new file mode 100644 index 0000000..8b3715d --- /dev/null +++ b/homework/01-guarantees/tests/src/tests_mc.rs @@ -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, 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 = 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::(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 = 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::(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 = 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::(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) + } +} diff --git a/homework/cli.py b/homework/cli.py new file mode 100644 index 0000000..f16f959 --- /dev/null +++ b/homework/cli.py @@ -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()) diff --git a/homework/readme.md b/homework/readme.md new file mode 100644 index 0000000..3fca7b6 --- /dev/null +++ b/homework/readme.md @@ -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` с литералом словаря. diff --git a/homework/requirements.txt b/homework/requirements.txt new file mode 100644 index 0000000..f1b76a3 --- /dev/null +++ b/homework/requirements.txt @@ -0,0 +1,2 @@ +httpx>=0.28,<1 +PyYAML>=6.0.2,<7