commit 25f425540460165b547bc806668fb3430595e981 Author: Даниил Date: Thu Oct 1 00:28:16 2026 +1000 MVP сайта diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..009fcc7 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +.env +.env.* +!.env.example +.git +.venv +/data/ +__pycache__ +*.pyc +.pytest_cache +tests +docs +*.egg-info +dist +build + +HockeyTeamParse/ diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f5c29ee --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Generate a real .env with: uv run --locked python -m parser_lab.cli init-env +LAB_ADMIN_USER=teacher +LAB_ADMIN_PASSWORD='REPLACE_WITH_YOUR_PASSWORD' +LAB_SECRET=REPLACE_WITH_RANDOM_SECRET_AT_LEAST_32_CHARACTERS +LAB_ORIGINS=http://localhost:8080 +LAB_SECURE_COOKIE=false +LAB_PORT=8080 +LAB_MAX_BYTES=26214400 +LAB_MAX_RECORDS=50000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b1b665f --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +.env +.env.* +!.env.example +.venv/ +/data/ +__pycache__/ +*.pyc +.pytest_cache/ +*.egg-info/ +build/ +dist/ +HockeyTeamParse/ +data/ \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e372f0b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +FROM python:3.12-slim + +COPY --from=ghcr.io/astral-sh/uv:0.8.14 /uv /uvx /bin/ + +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 LAB_DATA_DIR=/data \ + UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=never UV_NO_CACHE=1 \ + PATH="/app/.venv/bin:$PATH" + +WORKDIR /app + +COPY pyproject.toml uv.lock .python-version ./ + +RUN uv sync --locked --no-dev --no-install-project + +COPY src ./src +COPY examples ./examples + +RUN uv sync --locked --no-dev --no-editable && useradd --create-home --uid 10001 lab && mkdir /data && chown lab:lab /data + +USER lab + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s CMD uv run --no-sync python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/healthz',timeout=4)" + +CMD ["uv", "run", "--no-sync", "python", "-m", "parser_lab"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2acf704 --- /dev/null +++ b/README.md @@ -0,0 +1,189 @@ +# Parser Lab + +Учебный сайт для сбора данных: преподаватель импортирует CSV, JSON или JSONL, выбирает оформление и публикует две версии страниц — обычный HTML с пагинацией и HTML с динамической дозагрузкой. Приложение работает на FastAPI, Jinja2, HTML, CSS и небольших JavaScript-модулях без UI-фреймворков. Один аккаунт преподавателя; студентам вход не нужен. + +## Быстрый запуск через Docker + +Нужны Docker Engine с Compose либо Docker Desktop. Запускайте команды из папки проекта. + +1. Скопируйте `.env.example` в `.env` и соберите образ: + + ```sh + docker compose build + ``` + +2. Укажите логин и обычный пароль (не менее 10 символов) в `.env`: + + ```dotenv + LAB_ADMIN_USER=teacher + LAB_ADMIN_PASSWORD='ваш-пароль-для-входа' + ``` + + Одинарные кавычки сохраняют символы `$`, пробелы и `#`. Если пароль содержит кавычки или обратные слеши, используйте `uv run --locked python -m parser_lab.cli init-env` для создания корректного `.env` вместо ручного копирования шаблона. + +3. Создайте случайный секрет: + + ```sh + docker compose run --rm --no-deps app uv run --no-sync python -c "import secrets; print(secrets.token_urlsafe(48))" + ``` + + Поместите значение в `LAB_SECRET`. Не используйте пароль как секрет. + +4. Для локальной проверки оставьте `LAB_ORIGINS=http://localhost:8080` и `LAB_SECURE_COOKIE=false`. Запустите: + + ```sh + docker compose up -d + docker compose ps + ``` + +Откройте [кабинет](http://localhost:8080/admin). Проверка сервиса: [healthz](http://localhost:8080/healthz). + +Если меняете порт, например на 8087, измените одновременно `LAB_PORT=8087` и `LAB_ORIGINS=http://localhost:8087`. Имя хоста тоже имеет значение: localhost и 127.0.0.1 — разные origin. Через запятую можно указать несколько разрешённых адресов. + +## Размещение в интернете + +Разместите приложение в корне отдельного домена, например `https://lab.example.org`. Установите: + +```dotenv +LAB_ORIGINS=https://lab.example.org +LAB_SECURE_COOKIE=true +``` + +Передайте домен на reverse proxy с HTTPS. Пример nginx, если приложение доступно прокси на порту 8080: + +```nginx +location / { + proxy_pass http://127.0.0.1:8080; + proxy_http_version 1.1; + proxy_set_header Host $host; + client_max_body_size 26m; +} +``` + +TLS-сертификат и DNS настраиваются на вашем сервере. Ограничьте прямой доступ к порту приложения средствами сервера или привяжите порт в Compose к 127.0.0.1, если прокси находится на той же машине. Подкаталоги вида `/my-lab/` и несколько реплик приложения не поддерживаются первой версией. + +## Как опубликовать данные + +1. Войдите в кабинет и нажмите «Загрузить данные». +2. Загрузите файл. При необходимости выберите кодировку и разделитель CSV либо массив записей внутри JSON. +3. Нажмите «Проверить файл»: появятся количество записей, предупреждения и первые 20 строк. Сохраните импорт. +4. Выберите представление и включите его. Отметьте поля, задайте подписи, порядок, поиск и категориальный фильтр. Для карточек назначьте заголовок; для сообщений — текст, при желании автора и дату. +5. Проверьте живой предпросмотр, сохраните черновик и откройте полный предпросмотр. +6. Опубликуйте версию. В разделе публикаций появятся ссылки для студентов. + +Один набор может иметь все четыре представления. Изменения черновика не затрагивают опубликованные данные. Повторная публикация создаёт новые ссылки с номером версии; прежние продолжают работать. Снятие версии с публикации делает её недоступной. Уже скачанные студентом данные, разумеется, не исчезают. + +При замене исходного файла настройки представлений сбрасываются: их нужно проверить заново. Изменение названия после публикации разрешено, изменение адреса набора — нет. Если набор отредактирован в другой вкладке, сохранение сообщит о конфликте, а не затрёт изменения. + +## Форматы файлов + +| Формат | Поддержка | +|---|---| +| CSV | Первая строка — уникальные непустые заголовки; UTF-8 или Windows-1251; запятая, точка с запятой или табуляция | +| JSON | Массив объектов, одиночный объект или выбранный вложенный массив объектов | +| JSONL | По одному объекту JSON в каждой непустой строке; UTF-8 | + +Лимиты по умолчанию: 25 МиБ, 50 000 записей, глубина JSON 20. Размер файла и количество записей можно изменить через `LAB_MAX_BYTES` и `LAB_MAX_RECORDS`. Большие наборы требуют больше памяти: текущая версия хранит JSON-снимки в SQLite и загружает снимок для выборки. Проверяйте скорость на своих данных до занятия. + +Дубликаты и исходный порядок сохраняются. Значения CSV остаются строками, чтобы не потерять ведущие нули и исходное представление чисел. В JSON различаются отсутствие поля, null, пустая строка, false и 0. Ошибки формата блокируют импорт; повреждённые строки не пропускаются молча. Пустой набор не публикуется. + +Вложенные поля показаны путями: `/profile/age` означает поле age в объекте profile. Символ `/` в самом имени поля кодируется как `~1`, а `~` — как `~0`. Массивы сохраняются; специальные анкеты могут отображать их как пары вопросов и ответов. + +### Заполненные анкеты + +Для таблицы с колонками `participant, q1, q2` выберите «Колонки с ответами» и замените подписи q1/q2 текстами вопросов. + +Для `examples/data/questionnaires.json` достаточно выбрать файл, нажать «Проверить файл» и «Сохранить и настроить». Приложение распознаёт участников и пары `question`/`answer`, выбирает `/participants` и создаёт готовое представление «Анкеты». Предпросмотр сразу показывает двух участников и их ответы. Распознавание зависит от структуры данных, а не имени файла. + +Если структура неоднозначна, выберите уровень записей вручную. Для ранее загруженного целиком документа в редакторе доступна кнопка «Настроить анкеты автоматически»: она исправляет черновик без повторной загрузки исходного файла. Опубликованные версии сохраняются. + +Сайт отображает готовые данные; он не собирает ответы и не интерпретирует психологические шкалы. Дополнительные примеры синтетические. Хоккейный CSV перенесён из предыдущего проекта. + +## Парсинг + +Адреса имеют вид: + +```text +/html/study-abc/v/1/table +/interactive/study-abc/v/1/table +``` + +Для HTML данные находятся в исходном ответе сервера. Пагинация — `?page=2`, следующая ссылка — `a[rel=next]`. Стабильные селекторы: + +| Селектор | Значение | +|---|---| +| `.record[data-record-id]` | Запись со стабильным в пределах версии id | +| `.field[data-field]` | Значение выбранного поля | +| `data-value-state` | `missing`, `null` или `value` | +| `data-value-type` | Исходный тип: string, number, boolean, object, array, null или missing | +| `data-role` | Роль значения: title, text, author или date | +| `data-label` | Подпись поля или текст вопроса | +| `.detail-link` | Ссылка на отдельную запись | + +Фильтры HTML: `q` — регистронезависимый поиск в назначенном поле, `category` — JSON-представление скалярного значения. Формы на странице формируют правильные параметры автоматически. Поиск не выполняет морфологический анализ. При неправильном номере страницы возвращается 400, при отсутствии страницы или версии — 404. + +В динамическом варианте первые записи также находятся в HTML-ответе. Кнопка «Загрузить ещё», поиск и фильтры обновляют данные обычными HTTP-запросами через JavaScript. Для практики браузерного парсинга используйте Playwright. Оба варианта используют один шаблон, стили и favicon. + +Примеры решений находятся в `examples/parsers/` и не доступны как файлы через публичный сайт: + +```sh +uv sync --locked +uv run --locked playwright install chromium +uv run --locked python examples/parsers/scrape_html.py "http://localhost:8080/html/SLUG/v/1/table" --output html.jsonl +uv run --locked python examples/parsers/scrape_interactive.py "http://localhost:8080/interactive/SLUG/v/1/table" --output interactive.jsonl +``` + +Вместо загруженного Chromium можно использовать установленный Chrome: добавьте `--channel chrome` второму парсеру. + +## Обновление и резервная копия + +Данные находятся в named volume `lab-data`, смонтированном в `/data`. `docker compose down` сохраняет том. **Не используйте `down -v`, если данные нужны.** + +Обновление: + +```sh +docker compose down +docker compose build +docker compose up -d +``` + +Перед обновлением остановите контейнер и скопируйте весь каталог данных: + +```sh +docker compose stop app +docker compose cp app:/data ./backup-data +docker compose start app +``` + +Храните копию вместе с защищённой копией `.env`. Для восстановления подготовьте отдельный Compose-проект с новым томом, создайте его контейнер (`docker compose create`), скопируйте содержимое backup-data в `/data`, восстановите владельца UID 10001 при необходимости и запустите контейнер. Проверяйте восстановление отдельно от рабочего сайта. + +Импортированные файлы и старые снимки не удаляются автоматически. Следите за размером тома. Безвозвратное удаление истории не входит в интерфейс первой версии. + +## Локальная разработка + +Нужен [uv](https://docs.astral.sh/uv/getting-started/installation/) версии 0.8.14 или новее. Python 3.12 и окружение `.venv` управляются через uv. Запускайте команды из корня проекта: + +```sh +uv sync --locked +uv run --locked python -m parser_lab.cli init-env +uv run --locked --env-file .env python -m parser_lab +``` + +`init-env` один раз создаёт `.env` и запрашивает параметры преподавателя. При последующих запусках достаточно последней команды. Активация окружения не требуется. + +Зависимости приложения и группа `dev` описаны в `pyproject.toml`, точные версии зафиксированы в `uv.lock`. Для изменения зависимостей используйте `uv add ИМЯ` или `uv add --dev ИМЯ`; сохраняйте оба файла в Git. Устаревшие `requirements*.lock` больше не используются. Docker собирает production-окружение командой `uv sync --locked --no-dev` и запускает готовое окружение через `uv run --no-sync`. + +Параметры окружения имеют приоритет над локальной `.env`. Пароль LAB_ADMIN_PASSWORD и секрет LAB_SECRET обязательны, приложение откажется запускаться без них. После изменения пароля или секрета перезапустите приложение (для Docker: `docker compose up -d --force-recreate`); старые сессии станут недействительны. + +Тесты: + +```sh +uv run --locked pytest -q +# Дополнительные браузерные тесты: LAB_BROWSER_TESTS=1, при необходимости PLAYWRIGHT_CHANNEL=chrome. +``` + +Старые документы проектирования в `docs/` сохранены как история. Актуальная архитектура — FastAPI + Jinja2 + HTML/CSS/JavaScript; зависимости Rio полностью удалены. Существующие SQLite-данные и опубликованные ссылки совместимы с новой версией. Фактические результаты проверки и ограничения среды — в `docs/verification.md`. + +Справка: [uv в Docker](https://docs.astral.sh/uv/guides/integration/docker/), [запуск команд](https://docs.astral.sh/uv/concepts/projects/run/). + +Пароль хранится в `.env` обычным текстом. Внутренний Argon2-хеш для проверки входа приложение создаёт в памяти при запуске. Если `.env` остался от предыдущей версии, замените строку `LAB_ADMIN_HASH=...` на `LAB_ADMIN_PASSWORD='ваш-пароль'`; прежний хеш больше не используется. `.env` исключён из Git и Docker-образа. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..c29cefd --- /dev/null +++ b/compose.yaml @@ -0,0 +1,15 @@ +services: + app: + build: . + image: parser-lab:1.0 + ports: + - "${LAB_PORT:-8080}:8080" + env_file: .env + environment: + LAB_DATA_DIR: /data + volumes: + - lab-data:/data + restart: unless-stopped + stop_grace_period: 30s +volumes: + lab-data: diff --git a/docs/design-spec.md b/docs/design-spec.md new file mode 100644 index 0000000..860a01a --- /dev/null +++ b/docs/design-spec.md @@ -0,0 +1,156 @@ +> Архив первоначального проекта. С 2026-10-01 Rio удалён: приложение использует FastAPI, Jinja2 и HTML/CSS/JavaScript. Актуальное описание — в README.md. + +# Спецификация учебного сайта для парсинга данных + +Документ описывает первую версию сайта для занятий по основам ML, в том числе для психологов. Преподаватель загружает данные неизвестной заранее структуры и публикует их в разных представлениях. Студенты собирают опубликованные записи обычными HTTP-парсерами или браузерной автоматизацией. Реализация начнётся после согласования спецификации и плана. + +## Согласованные требования + +- Один преподаватель, закрытый кабинет на Python Rio, вход по логину и паролю без регистрации. +- Публичные учебные страницы без аккаунтов студентов. +- Импорт CSV, JSON и JSONL через кабинет. +- Таблицы, карточки, сообщения и заполненные анкеты; сбор новых ответов не входит в первую версию. +- Два режима доступа к одному набору: HTML для requests, BeautifulSoup и Scrapy; динамический Rio для Playwright и Selenium. +- Предпросмотр, черновики, публикация и снятие с публикации. +- Docker Compose, постоянное хранение данных и возможность разместить приложение на выбранном сервере. +- Современное адаптивное оформление и сохранение исходных пропусков и неоднородных значений. + +Описанные ниже конкретные правила, пределы и адреса являются предлагаемыми решениями для первой версии. + +## Архитектура + +Один Python-процесс и контейнер обслуживают Rio и HTML-маршруты через FastAPI. Кабинет расположен под `/admin`, динамические страницы под `/interactive`, серверные HTML-страницы под `/html`. Конкретную интеграцию проверяем на закреплённой версии rio-ui до разработки остальных экранов. + +Импорт, хранение, настройка полей и выборка записей образуют общее ядро. Оба механизма отображения получают одинаковые записи и конфигурацию; логика фильтрации и пагинации не дублируется. HTML генерируется сервером из шаблонов с экранированием текста. Полезное содержимое присутствует в исходном HTTP-ответе без запуска JavaScript. + +SQLite хранит наборы, записи, настройки представлений, черновики и публикации. Исходные файлы находятся отдельно в постоянном каталоге. Данные пользователя не размещаются в публичном каталоге статических файлов. Схема базы имеет версию и последовательные миграции. + +Модули разделяются по ответственности: импорт; модель данных и хранилище; публикации; авторизация; кабинет Rio; динамические представления Rio; HTML-маршруты и шаблоны. Запуск приложения не требует pandas для каждого запроса. + +## Путь преподавателя и экраны + +1. **Вход.** Логин и пароль, понятная ошибка без раскрытия существования пользователя, выход из кабинета. +2. **Каталог.** Название набора, число записей, статус, время изменения и действия «Редактировать», «Предпросмотр», «Опубликовать», «Снять с публикации». +3. **Импорт.** Выбор файла, параметры чтения, список ошибок и первые 20 записей. После подтверждения создаётся черновик; исходный файл сохраняется. +4. **Настройка.** Название, описание, адрес набора, представления, поля, подписи и количество записей на странице. +5. **Предпросмотр.** Проверка обоих режимов с реальными данными черновика. Предпросмотр доступен только преподавателю. +6. **Публикация.** Итоговая проверка, создание версии и ссылки на HTML и Rio для каждого включённого представления. + +Изменения сохраняются явной кнопкой. При уходе с несохранёнными изменениями показывается предупреждение. Замена файла создаёт новую редакцию черновика и требует повторной проверки соответствия полей. + +## Правила импорта + +Начальные настраиваемые ограничения: файл до 25 МиБ, до 50 000 записей, глубина JSON до 20 уровней. Превышение вызывает понятную ошибку до публикации. Это границы приёма, а не обещание конкретной скорости работы; производительность проверяется отдельно на целевом оборудовании. + +### CSV + +Поддерживаются UTF-8 с BOM и без него, а также выбираемая Windows-1251. Преподаватель может указать запятую, точку с запятой или табуляцию; автоматическое предположение видно в предпросмотре. Первая строка содержит заголовки. Пустые и повторяющиеся заголовки блокируют импорт с указанием колонок. Поддерживаются кавычки, разделители и переводы строк внутри значений. + +Значения сохраняются строками, включая ведущие нули, даты и десятичные запятые. Пустая ячейка остаётся пустой строкой. Ошибка количества полей или синтаксиса сообщает позицию; повреждённые строки не пропускаются молча. + +### JSON + +Вход — массив объектов, одиночный объект как одна запись либо объект, внутри которого преподаватель выбирает массив объектов. Выбор вложенного массива не выполняется скрыто. Массив скалярных значений и смесь объектов со скалярами отклоняются с объяснением поддерживаемой структуры. Повторяющиеся ключи объектов и нестандартные NaN/Infinity отклоняются, чтобы исключить потерю значений. + +Объекты и массивы сохраняются вложенными. Поля выбираются деревом структуры; внутри конфигурации используются однозначные пути JSON Pointer. Отсутствующее поле, null, пустая строка, false и 0 остаются различимыми. + +### JSONL + +Каждая непустая строка содержит один JSON-объект. Пустые строки игнорируются с отображением их количества. Некорректная строка блокирует сохранение импорта; сообщение содержит номер строки и причину. Используется UTF-8 с допустимым BOM в начале файла. + +### Общие правила + +Порядок записей сохраняется. Дубликаты и пропуски автоматически не удаляются. Каждая запись получает внутренний идентификатор, который не зависит от наличия уникального поля в файле и стабилен внутри опубликованной версии. Поля определяются по всему принятому набору, а не только по предпросмотру. Если необязательного поля нет в части записей, это отражается при настройке. + +Некорректный импорт не заменяет существующие данные. Файлы хранятся под внутренними именами; пользовательское имя используется только для отображения. Загруженное содержимое считается данными и не исполняется. + +## Представления + +| Вид | Настройка | Отображение | +|---|---|---| +| Таблица | Произвольные выбранные поля, подписи и порядок колонок | Заголовки и строки, переходы на страницу записи | +| Карточки | Заголовок, необязательное описание и дополнительные поля | Сетка карточек с переходом к деталям | +| Сообщения | Текст, необязательные автор и дата, дополнительные поля | Линейная лента сообщений без веток обсуждений | +| Анкеты | Идентификатор или заголовок участника, выбранные ответы | Заполненный бланк с вопросами и значениями | + +Для анкет поддерживаются два явных способа настройки: колонки как ответы с подписями вопросов либо вложенный массив пар «вопрос — ответ», для которого задаются пути к обоим значениям. Нет расчёта шкал, интерпретации результатов или конструктора опросов. + +Один набор может включать несколько представлений с независимыми настройками. Отсутствие необходимого поля, например текста сообщения, блокирует публикацию этого представления. Необязательные поля можно не назначать. Объекты и массивы в обычной ячейке выводятся как экранированный JSON; специальный способ вывода вложенных анкет применяется только при явной настройке. + +Для отсутствующего значения и null используются разные видимые обозначения; пустая строка остаётся пустой. Их семантика также доступна через атрибут состояния значения в HTML. Исходный тип не определяется по внешнему виду текста. + +## Учебные режимы + +Пример адреса HTML: `/html/study-a/v/1/table?page=2`. Пример Rio: `/interactive/study-a/v/1/table`. У каждого представления есть собственный сегмент адреса. В ссылках содержится версия публикации. + +### Обычный HTML + +Таблицы используют table, thead, tbody, th и td. Остальные представления используют обычные семантические блоки, заголовки и ссылки. Записи и значения имеют документированные устойчивые CSS-классы и идентификаторы. Ни данные, ни переходы не требуют JavaScript, WebSocket или особого User-Agent. + +Предлагаемые размеры страниц: 10, 25, 50, 100; по умолчанию 25. Порядок соответствует импорту. Страницы доступны через реальные ссылки. Нецелый, отрицательный или нулевой номер возвращает 400; номер за пределами набора — 404. Публикация пустого набора запрещена. Индивидуальные страницы записей дают упражнение на переходы по ссылкам. + +### Динамический Rio + +Первая порция содержит настроенное число записей. Кнопка «Загрузить ещё» добавляет следующую порцию, показывает состояние загрузки и исчезает после последней записи. Повторные нажатия во время запроса не создают дубликатов. + +Преподаватель выбирает одно текстовое поле для поиска и, при необходимости, одно поле категориального фильтра. Изменение фильтра сбрасывает список и позицию загрузки. Доступны состояния ожидания, отсутствия результатов и ошибки. Динамические упражнения не включают CAPTCHA, искусственные задержки или противодействие парсерам. + +В HTML те же фильтры передаются query-параметрами и применяются сервером. Для обоих режимов поиск — регистронезависимое вхождение в выбранное текстовое поле, категориальный фильтр — точное совпадение значения. Результат всегда сохраняет исходный порядок. + +Публичная выдача исходного файла и отдельный JSON API не включаются в первую версию. Это учебная организация доступа, а не гарантия, что видимые данные невозможно получить другим способом. Сервер отдаёт только поля, включённые в соответствующее представление; скрытые поля не передаются клиенту внутри полного объекта записи. + +## Публикации и сохранность + +Публикация атомарно создаёт неизменяемый снимок записей и настроек всех включённых представлений. Неудачная операция оставляет предыдущую публикацию доступной. Изменения черновика не затрагивают опубликованные версии. + +Повторная публикация создаёт новый номер и новые ссылки. Старые ссылки продолжают показывать прежний материал до снятия соответствующей версии с публикации. Это предотвращает изменение данных между запросами студенческого парсера. Снятая версия возвращает 404 в обоих режимах; черновик остаётся у преподавателя. В первой версии достаточно снятия с публикации, без интерфейса безвозвратного удаления истории. + +## Внешний вид + +Интерфейс на русском языке: светлый фон, тёмный читаемый текст, спокойный сине-зелёный акцент, единые размеры отступов и контрастные состояния элементов. Кабинет использует боковую навигацию на широком экране и компактную на узком. Основные действия визуально выделены, технические параметры появляются только там, где нужны для выбора. + +Таблицы прокручиваются горизонтально, длинные сообщения переносятся, анкеты сохраняют связь вопроса и ответа. Карточки перестраиваются в одну колонку на телефоне. HTML и Rio имеют общий визуальный стиль, но не требуют идентичной внутренней разметки. Шрифты и необходимые статические ресурсы поставляются с приложением, чтобы интерфейс не зависел от внешнего CDN. + +## Авторизация и Docker + +Учётные данные задаются при развёртывании: имя пользователя и хеш пароля через переменные окружения или файл секрета. Открытая регистрация и стандартный встроенный пароль отсутствуют. Инструкция содержит способ создания хеша и смены пароля. Вход ограничивается по частоте; сессия защищает все операции кабинета, предпросмотр и доступ к исходным файлам. Изменяющие операции проверяют подлинность сессии и защищены от CSRF. Пароли и данные анкет не попадают в журналы запросов. + +Контейнер запускается непривилегированным пользователем, слушает порт 8080 и использует `/data` как постоянный том. SQLite и исходные файлы находятся в этом томе. Порт хоста настраивается через Compose; доступны проверка работоспособности и корректное завершение процесса. При отсутствии настроек входа приложение сообщает ошибку запуска, а не открывает кабинет без защиты. + +HTTPS завершается на внешнем reverse proxy, который передаёт HTTP и WebSocket. Поддерживаемый вариант первой версии — приложение в корне отдельного домена; размещение под произвольным префиксом URL не обещается. При HTTPS cookie сессии получает Secure; для локального HTTP есть явная настройка. Работа нескольких реплик с общей SQLite в первую версию не входит. + +В комплект входят Dockerfile, compose.yaml, пример переменных окружения без секретов и инструкция запуска, обновления и резервного копирования. Простой согласованный способ резервного копирования — остановить контейнер и скопировать весь том; восстановление проверяется отдельно. Данные не встраиваются в образ как изменяемое хранилище. + +## Демонстрационные данные + +Старый хоккейный CSV сохраняется в качестве примера таблицы. Дополнительные небольшие синтетические наборы демонстрируют сообщения, карточки и оба формата заполненных анкет. Примеры включают Unicode, пустые значения, длинный текст и повторяющиеся значения. Демонстрационные наборы импортируются явно и не перезаписывают рабочие данные при перезапуске. + +Старый NiceGUI-проект остаётся источником примера, а не основой нового интерфейса. Совместимость старого адреса `?pageNum=` не входит в первую версию. + +## Проверка и критерии готовности + +1. Преподаватель импортирует каждый поддерживаемый формат без редактирования кода; ошибки показывают причину и расположение. +2. Назначение полей работает с произвольными заголовками и вложенными значениями. Несовместимые настройки нельзя опубликовать. +3. Для одного набора включаются несколько представлений, и каждое доступно в обоих режимах. +4. Парсер requests и BeautifulSoup собирает все видимые записи по ссылкам и страницам без пропусков и дублей. Проверяются значения, а не только количество. +5. Парсер Playwright получает те же записи, используя динамическую загрузку и фильтры. При отсутствии фильтров порядок совпадает с HTML. +6. Редактирование и повторная публикация не изменяют старые опубликованные ссылки. Черновики, исходные файлы и скрытые поля недоступны студентам. +7. Замена контейнера сохраняет данные; резервная копия восстанавливается в чистом окружении. +8. Через браузер проверяются все четыре представления, узкий экран, длинные тексты, пустые значения, вход и ошибки загрузки. +9. Контейнер собирается из документированной команды; проверяются старт, HTTP, WebSocket, том и завершение. + +Примеры парсеров передаются преподавателю отдельными файлами, без автоматической публикации решений на учебном сайте. Версии Python и библиотек закрепляются после проверки совместимости Rio, FastAPI и контейнера. До этого документация текущего Rio подтверждает направление интеграции, но не заменяет проверку установленной версии. + +## Границы первой версии + +Не входят: несколько преподавателей, аккаунты студентов, сбор ответов, оценивание работ, обучение моделей, произвольный редактор HTML, ветки комментариев, импорт по внешним URL, публичный API выгрузки, горизонтальное масштабирование и автоматическое размещение на конкретном сервере. Объём первой версии заканчивается работающим проверенным Docker-пакетом и инструкциями. + +## Последовательность дальнейшей работы + +После проверки этой спецификации готовится отдельный план реализации с файлами, зависимостями, этапами и проверками. Порядок: совместимость Rio и HTML в одном приложении; хранение и вход; импорт; настройка представлений; публикации и оба режима; оформление; парсеры и проверка Docker. Продуктовый код в рамках подготовки спецификации не изменяется. + +## Основания технических решений + +Изученный старый проект расположен в `P:\Coding\Python\NetologyWebParserSite\HockeyTeamParse`: NiceGUI, pandas, один CSV, страницы по 25 строк, Docker Compose. Окружение с установленным Rio в изученном проекте не найдено. + +Официальные источники Rio, проверенные при обсуждении: [App и as_fastapi](https://github.com/rio-labs/rio/blob/main/rio/app.py), [сервер и динамическая загрузка интерфейса](https://github.com/rio-labs/rio/blob/main/rio/app_server/fastapi_server.py). Архитектура этого документа является проектным предложением, а не утверждением о готовности перечисленных функций в старом приложении. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md new file mode 100644 index 0000000..cd54ea2 --- /dev/null +++ b/docs/implementation-plan.md @@ -0,0 +1,210 @@ +> Архив первоначального проекта. С 2026-10-01 Rio удалён: приложение использует FastAPI, Jinja2 и HTML/CSS/JavaScript. Актуальное описание — в README.md. + +# План реализации учебного сайта для парсинга + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans for implementation in this session, or superpowers:subagent-driven-development if the user chooses delegation. Steps use checkbox syntax for tracking. + +**Goal:** Создать универсальный учебный сайт с кабинетом преподавателя на Rio, импортом данных, HTML и динамическими упражнениями, готовый к запуску через Docker Compose. + +**Architecture:** Один Python-сервис объединяет Rio и FastAPI с Jinja2. Импорт, выборка, публикации и SQLite общие для обоих режимов; публичные запросы читают неизменяемые версии. + +**Tech Stack:** Python, rio-ui, FastAPI, Jinja2, SQLite через sqlite3, Argon2 для пароля, pytest, requests, BeautifulSoup, Playwright, Docker Compose. Точные версии закрепляются после проверки совместимости в задаче 1. + +**Spec:** [Утверждённая спецификация](design-spec.md). + +## Рабочее место и ограничения + +Новый проект создаётся в `outputs/parser-lab/` относительно текущего рабочего каталога. Все пути ниже относятся к этому корню. Старый проект `P:\Coding\Python\NetologyWebParserSite\HockeyTeamParse` служит источником хоккейного примера и не перезаписывается. Документы копируются в `docs/` нового проекта. Временные результаты проверок находятся в `work/` текущего рабочего каталога. При наличии Git этапы фиксируются отдельными коммитами; отсутствие Git не блокирует сборку и передачу проекта. + +- Один аккаунт преподавателя; без регистрации и аккаунтов студентов. +- Четыре представления; оба режима для каждого включённого представления. +- Файл до 25 МиБ, до 50 000 записей, глубина JSON до 20 уровней; пределы настраиваемы. +- Размеры страниц 10, 25, 50, 100; исходное значение 25. Предпросмотр первых 20 записей. +- Unicode, пропуски, дубликаты, порядок и исходные типы сохраняются; CSV остаётся строковым. +- Русский интерфейс, светлая тема, адаптивность, локальные ресурсы. +- Неизменяемые публикации, отсутствие доступа к черновикам и скрытым полям извне. +- Один контейнер, порт 8080, постоянный каталог `/data`, HTTP и WebSocket за reverse proxy. +- Нет автоматического деплоя на внешний сервер, обучения моделей или публичного API выгрузки. + +## Особое внимание при проверке + +1. Заголовки JSON с точками, слешами и тильдами не должны путаться с вложенными путями — задачи 2 и 4. +2. Значения null, false, 0 и пустая строка не должны схлопываться в отсутствие поля — задачи 2 и 5. +3. Замена файла и авария публикации не должны повреждать доступный студентам снимок — задачи 3 и 7. +4. Прямой запрос страницы деталей или callback Rio не должен обходить авторизацию либо раскрывать скрытое поле — задачи 6, 8 и 10. +5. Две вкладки преподавателя не должны незаметно перезаписывать чужие изменения — задача 3; сохранять черновик с ожидаемой ревизией и показывать конфликт. + +## Карта файлов + +| Файлы | Ответственность | +|---|---| +| `pyproject.toml`, `requirements.lock`, `requirements-dev.lock` | Зависимости приложения и проверок | +| `src/parser_lab/app.py`, `config.py`, `__main__.py` | Сборка сервиса, конфигурация, запуск | +| `src/parser_lab/domain.py`, `paths.py` | Общие типы и JSON Pointer | +| `src/parser_lab/importing/csv_reader.py`, `json_reader.py`, `service.py` | Разбор файлов и диагностика | +| `src/parser_lab/storage/database.py`, `repository.py`, `migrations/001.sql` | База, миграции, транзакции | +| `src/parser_lab/views.py`, `query.py`, `publishing.py` | Конфигурация представлений, выборка, публикация | +| `src/parser_lab/auth.py`, `cli.py` | Сессии, вход, создание хеша пароля | +| `src/parser_lab/web/routes.py`, `templates/`, `static/` | HTML, стили и ресурсы | +| `src/parser_lab/rio_ui/admin/`, `rio_ui/public/`, `rio_ui/theme.py` | Кабинет, динамические страницы, тема | +| `tests/unit/`, `tests/integration/`, `tests/browser/` | Проверки поведения | +| `examples/data/`, `examples/parsers/` | Данные и решения для преподавателя | +| `Dockerfile`, `compose.yaml`, `.env.example`, `.dockerignore`, `README.md` | Развёртывание и использование | + +Каждая задача включает запуск своей проверки до реализации, подтверждение ожидаемого сбоя, реализацию, повторную проверку и фиксацию результата. Ошибки настройки тестового окружения не считать ожидаемым провалом поведения. + +## Задача 1 Совместимый каркас сервиса + +**Files:** создать `pyproject.toml`, оба lock-файла, `src/parser_lab/{__init__,app,config,__main__}.py`, `tests/integration/test_app.py`. + +**Interfaces:** `Settings.from_env() -> Settings`; `create_app(settings: Settings) -> FastAPI`. Settings содержит каталог данных, лимиты импорта, имя администратора, хеш пароля, секрет сессий, secure-cookie и доверенные origin. В тестах настройки передаются явно. + +- [ ] Проверить доступные Python и Docker, прочитать API установленной для проекта Rio, выбрать поддерживаемую Python и закрепить совместимые версии. Не обновлять глобальное окружение пользователя. +- [ ] Добавить тест `test_html_route_coexists_with_rio`: `client.get('/healthz').status_code == 200`, HTML-проба содержит `' ImportResult`; `resolve_pointer(record: Record, pointer: str) -> ResolvedValue(present, value)`. + +- [ ] Добавить проверки: CSV `'id;answer\n001;\n'` даёт `{'id': '001', 'answer': ''}`; JSON сохраняет `False`, `0`, `None`; `resolve_pointer({'a/b': {'~c': 0}}, '/a~1b/~0c').value == 0`; отсутствующий путь имеет `present == False`. +- [ ] Добавить параметризованные случаи: UTF-8 BOM, Windows-1251, многострочные CSV-ячейки, плохая длина строки, повторные заголовки и JSON-ключи, JSONL с ошибкой на строке 3, пустые строки JSONL, одиночный объект, вложенный массив, NaN/Infinity, глубина 21, лимиты размера и количества, массив скаляров. +- [ ] Выполнить `python -m pytest tests/unit/test_importing.py tests/unit/test_paths.py -q`, проверить ожидаемые провалы. +- [ ] Реализовать чтение с `csv` и `json`, строгой обработкой повторяющихся ключей, ограничением размера до разбора и контролем глубины/числа записей. Не угадывать формат по пользовательскому имени без проверки содержимого. JSONL читает построчно; результаты не сохраняются частично. +- [ ] Повторить тесты. Проверить объединение полей по всем записям, явный выбор вложенного массива и различение пустого набора от ошибки. + +## Задача 3 Хранение черновиков и исходных файлов + +**Files:** создать `storage/{__init__,database,repository}.py`, `storage/migrations/001.sql`, `tests/integration/test_repository.py`. + +**Interfaces:** `Repository.create_draft(title: str, source: bytes, imported: ImportResult) -> Draft`; `get_draft(dataset_id: str) -> Draft`; `save_draft(draft: Draft, expected_revision: int) -> Draft`; `replace_import(dataset_id: str, source: bytes, imported: ImportResult, expected_revision: int) -> Draft`. `Draft` содержит id, slug, revision, записи, источник, представления, название и описание. Ошибки: `NotFound`, `RevisionConflict`. + +- [ ] Добавить тесты сохранения и повторного открытия SQLite: `reopened.records == imported.records`; повторное сохранение со старой revision вызывает `RevisionConflict`; авария транзакции не меняет старую запись. Проверить, что исходное имя `'../../outside.json'` не влияет на фактический путь хранения. +- [ ] Запустить `python -m pytest tests/integration/test_repository.py -q` до реализации. +- [ ] Реализовать таблицы datasets, draft_records, sources, views, publications, published_records, sessions, schema_version. Записи хранятся JSON вместе с ordinal и внутренним id. Все SQL параметризованы; опубликованный снимок не зависит от изменяемого черновика. +- [ ] Реализовать файлы под UUID, временную запись с последующим переносом в пределах `/data`, транзакционную привязку к базе и очистку несвязанных временных файлов. Замена импорта сбрасывает подтверждение настройки полей. +- [ ] Повторить тесты и проверить идемпотентный запуск миграции и откат при ошибке записи файла. + +## Задача 4 Настройки представлений + +**Files:** создать `views.py`, `tests/unit/test_views.py`; дополнить общие модели в `domain.py`. + +**Interfaces:** `ViewConfig(kind, columns, roles, questionnaire_mode, questions_pointer, page_size, search_pointer, category_pointer)`; `FieldConfig(pointer, label)`; `validate_view(config: ViewConfig, records: list[Record]) -> list[ImportIssue]`; `project_record(record: Record, config: ViewConfig) -> DisplayRecord`. `DisplayRecord` содержит только назначенные значения и сведения об их наличии/типе. + +- [ ] Добавить проверки для всех четырёх видов: произвольные подписи колонок; текст сообщения обязателен в конфигурации, автор — нет; анкета из колонок и массива question/answer; отсутствующее значение в отдельной записи допустимо и помечается. Путь, которого нет ни в одной записи, не допускается как обязательная роль. +- [ ] Проверить страницу 25 по умолчанию и отклонение 24; выбор только текстового поля для поиска; категориальное поле должно быть скалярным. Проверить, что `secret` отсутствует во всём сериализованном DisplayRecord, когда оно не выбрано. +- [ ] Запустить `python -m pytest tests/unit/test_views.py -q` до реализации. +- [ ] Реализовать явные конфигурации без эвристического назначения смысла полям. JSON Pointer отвечает за чтение и экранирование имён; структура анкеты не смешивается с универсальным табличным рендером. +- [ ] Повторить тесты, включая слеши и тильды в именах полей, смешанные записи и многострочные ответы. + +## Задача 5 Общая выборка и HTML + +**Files:** создать `query.py`, `web/{__init__,routes}.py`, `web/templates/{base,list,detail,table,cards,messages,questionnaire}.html`, `web/static/site.css`, `tests/unit/test_query.py`, `tests/integration/test_html.py`. + +**Interfaces:** `Query(page=1, search='', category=None)`; `select_records(records: list[StoredRecord], config: ViewConfig, query: Query) -> PageResult`; `PageResult(items, total, page, page_size, has_next)`. `StoredRecord(id, ordinal, data)` используется обоими режимами. `register_html_routes(app: FastAPI, repository: Repository) -> None` после задачи 7 читает Publication; до этого HTML тестируется на явной фикстуре снимка. + +- [ ] Добавить тесты выборки: 51 запись и размер 25 дают страницы 25/25/1, ids не пересекаются; поиск регистронезависимый, фильтр точный и сохраняет различие числа 0 и строки `'0'`; порядок стабилен. +- [ ] Добавить тест HTML: `BeautifulSoup(response.text, 'html.parser').select('tbody tr')` содержит нужные значения без JavaScript. Вредоносное значение `` остаётся текстом, исполняемый тег не появляется. Проверить отсутствие скрытых полей на списке и деталях. +- [ ] Запустить `python -m pytest tests/unit/test_query.py tests/integration/test_html.py -q` до реализации. +- [ ] Реализовать общий отбор, Jinja2 с autoescape, реальные ссылки пагинации и деталей. CSS-контракт: `.record`, `.field`, `data-record-id`, `data-field`, `data-value-state`; состояния `missing`, `null`, `value`. Ссылки сохраняют фильтры и версию, значения URL кодируются. +- [ ] Определить детали по `/html/{slug}/v/{version}/{view}/records/{id}`. Некорректная страница — 400, отсутствующая — 404. Для фильтра без совпадений страница 1 возвращает 200 с пустым состоянием; следующие — 404. +- [ ] Повторить тесты и проверить ручным HTTP-запросом, что содержимое и ссылки входят в исходный ответ. + +## Задача 6 Авторизация преподавателя + +**Files:** создать `auth.py`, `cli.py`, `tests/integration/test_auth.py`; дополнить `app.py`, `config.py`. + +**Interfaces:** `AuthService.login(username: str, password: str, client_key: str) -> SessionToken`; `require_admin(token: str) -> AdminSession`; `logout(token: str) -> None`; CLI `python -m parser_lab.cli hash-password` читает пароль скрытым вводом. + +- [ ] Написать тесты: неверный пароль не создаёт сессию; приватный маршрут без сессии отклонён; выход отзывает сессию; изменяющий POST без CSRF отклонён; внешний Origin при WebSocket/callback отклонён; cookie HttpOnly/SameSite и Secure соответствуют настройке. +- [ ] Зафиксировать предел входа: 5 неудачных попыток на client_key за 5 минут, далее 429 до истечения окна; срок сессии 12 часов. Добавить тест управляемым временем, без реального ожидания. Не доверять произвольному X-Forwarded-For. +- [ ] Запустить `python -m pytest tests/integration/test_auth.py -q` до реализации. +- [ ] Реализовать Argon2, случайный токен с хранением только его хеша, проверку срока сессии в каждом защищённом действии. HTML-формы и Rio-сессии используют одну авторизацию; редирект страницы сам по себе не считается защитой callback. +- [ ] Повторить тесты, проверить отсутствие паролей в логах и безопасный отказ запуска без обязательных настроек. Изменение учётных данных отзывает прежние сессии. + +## Задача 7 Неизменяемые публикации + +**Files:** создать `publishing.py`, `tests/integration/test_publications.py`; дополнить repository и HTML routes. + +**Interfaces:** `PublishingService.publish(dataset_id: str, expected_revision: int) -> Publication`; `get_publication(slug: str, version: int) -> Publication`; `unpublish(slug: str, version: int) -> None`. `Publication` содержит slug, version, title, description, views и снимок records. Неопубликованная версия вызывает `NotFound`. + +- [ ] Добавить тест: после публикации A и изменения черновика на B `get_publication(slug, 1).records` остаются A; повторная публикация создаёт version 2. Проверить откат при сбое копирования, запрет пустого набора и конфликт revision. +- [ ] Запустить `python -m pytest tests/integration/test_publications.py -q` до реализации. +- [ ] Реализовать атомарную публикацию всех включённых валидных представлений и отдельное снятие версии. Если одно включённое представление невалидно, публикация целиком отклоняется с указанием причины. +- [ ] Закрепить slug после первой публикации, чтобы переименование заголовка не ломало ссылки. Разрешить ASCII lowercase, цифры и дефисы; уникальность проверяет база. +- [ ] Подключить HTML к реальным снимкам и повторить тесты: снятая версия и неизвестный id возвращают 404; прежние ссылки остаются прежними после редактирования. + +## Задача 8 Кабинет Rio + +**Files:** создать `rio_ui/admin/{__init__,login,catalog,import_wizard,view_editor,preview,publications}.py`, `rio_ui/theme.py`, `tests/browser/test_admin.py`. + +**Interfaces:** компоненты `LoginPage`, `CatalogPage`, `ImportWizard`, `ViewEditor`, `PreviewPage`, `PublicationsPage` получают сервисы через подтверждённый механизм Rio attachment. Состояние несохранённых изменений принадлежит конкретной сессии, постоянные данные — Repository. Вызовы методов изменения требуют `require_admin` и expected_revision. + +- [ ] Создать браузерные сценарии входа, импорта CSV, выбора полей, чернового предпросмотра, публикации и выхода; до реализации сценарии не находят соответствующие экраны. +- [ ] Реализовать каталог и мастер с шагами «Файл», «Проверка», «Отображение», «Предпросмотр». Внешние названия не содержат JSON Pointer; выбор путей показан деревом. Ошибка импорта сохраняет выбранные настройки и объясняет причину. +- [ ] Реализовать оба предпросмотра за авторизацией, смену представлений, предупреждение о несохранённых изменениях и сообщение о конфликте двух вкладок. Дублирующие операции отключаются до завершения запроса. +- [ ] При работе FileInfo прочитать загруженные байты один раз и хранить до подтверждения/отмены; валидация повторяется на сервере, а не только фильтром расширений. Предпросмотр показывает 20 записей, число всех записей и предупреждения. +- [ ] Выполнить `python -m pytest tests/browser/test_admin.py -q` с браузером Playwright и отдельной временной базой. Проверить ручной вход, восстановление после ошибки и невозможность действия после logout в другой вкладке. + +## Задача 9 Динамические упражнения Rio + +**Files:** создать `rio_ui/public/{__init__,listing,detail,renderers}.py`, `tests/browser/test_interactive.py`. + +**Interfaces:** `InteractivePage` загружает Publication по slug/version и использует `select_records` и `project_record`; `RecordPage` показывает только выбранные значения. Состояние страницы хранит фильтры, next_page, generation и флаг loading. + +- [ ] Создать сценарий: 51 запись, размер 25; после двух нажатий «Загрузить ещё» получены 51 уникальных id, кнопки больше нет. Проверить каждое из четырёх представлений. +- [ ] Проверить быстрые повторные клики, смену фильтра во время загрузки и снятие публикации при открытой странице. Устаревший ответ фильтра не должен добавляться к новому списку; снятые данные не выдаются следующими запросами. +- [ ] Запустить `python -m pytest tests/browser/test_interactive.py -q` до реализации. +- [ ] Реализовать дозагрузку, состояния и детали, используя подтверждённые Rio API. Стабильные доступные подписи и роли обязательны; способы установки дополнительных DOM-атрибутов сначала проверить по версии Rio. Не строить тесты на случайных сгенерированных классах. +- [ ] Повторить сценарии; проверить прямое открытие вложенного URL, обновление браузера и отсутствие скрытых значений в передаваемых данных. + +## Задача 10 Дизайн и проверка пользовательского сценария + +**Files:** доработать `rio_ui/theme.py`, Rio-компоненты, `web/static/site.css`, HTML-шаблоны; создать `tests/browser/test_layout.py`, `tests/integration/test_visibility.py`. + +- [ ] Реализовать общие цветовые и типографические параметры, боковую навигацию, компактную навигацию, карточки статусов и локальные ресурсы. Не добавлять новые функции ради оформления. +- [ ] Проверить 1440×900 и 390×844: основные действия доступны; длинный текст переносится; горизонтально прокручивается таблица, а не вся страница; заголовки и ответы не перекрываются. Сохранить скриншоты в рабочую папку и визуально просмотреть. +- [ ] Проверить клавиатурный фокус, подписи полей, ошибки рядом с полями и состояния пустых результатов. Для простых косметических правок использовать визуальную проверку, не тестировать каждую константу стиля. +- [ ] Добавить интеграционные тесты скрытых полей на списках, деталях, HTML предпросмотре без входа и прямых служебных запросах. Проверить пользовательские ` + +
+{% if screen=='catalog' %} +
КАБИНЕТ / КОЛЛЕКЦИИ

Коллекции данных

Всего коллекций: {{ datasets|length }}

Загрузить данные
+
{% for d in datasets %}{% else %}{% endfor %}
НазваниеЗаписиПредставленияСтатус
{{ d.title }}
{{ d.filename }}
{{ d.records|length }}{{ d.views|length }}{{ 'Опубликовано' if active[d.id] else 'Черновик' }}Открыть
Коллекций пока нет. Загрузите первый набор данных.
+{% elif screen=='import' %} +
КОЛЛЕКЦИИ / ИМПОРТ

Импорт данных

Выберите файл, проверьте структуру и сохраните коллекцию.

+
+{% if replace %}

Замена файла сбросит настройки представлений. Опубликованные версии сохранятся.

{% endif %} +
К коллекциям
+{% elif screen=='edit' %} +
КОЛЛЕКЦИИ / РЕДАКТОР

{{ draft.title }}

{{ draft.records|length }} записей · {{ draft.filename }}

+{% if questionnaire_repair %}
Найдены анкеты: {{ questionnaire_repair.records|length }} участников.

{% if questionnaire_repair.pointer %}Участники находятся внутри {{ questionnaire_repair.pointer }}. Можно разделить их на отдельные записи и автоматически назначить вопросы и ответы. Настройки представлений черновика будут пересозданы; опубликованные версии сохранятся.{% else %}Можно автоматически назначить заголовки участников, вопросы и ответы.{% endif %}

{% endif %}
Предпросмотр ↗Публикации
+
+

О коллекции

Заменить исходный файл
+

Предпросмотр изменений

Первые 5 записей · Опубликованные данные изменятся только после создания новой версии.

+{% elif screen=='versions' %} +
КОЛЛЕКЦИИ / ПУБЛИКАЦИИ

{{ draft.title }}

← Настройка
+{% for v in versions %}

Версия {{ v.version }} · {{ 'Доступна' if v.active else 'Снята с публикации' }}

{% for k in v.kinds %}{% endfor %}{% if v.active %}{% endif %}
{% else %}

Опубликованных версий пока нет.

{% endfor %} +{% endif %}
diff --git a/src/parser_lab/web/templates/base.html b/src/parser_lab/web/templates/base.html new file mode 100644 index 0000000..1e662ae --- /dev/null +++ b/src/parser_lab/web/templates/base.html @@ -0,0 +1,3 @@ + +{{ title|default('Parser Lab') }} +{% include 'header.html' %}{% block content %}{% endblock %}{% include 'footer.html' %} diff --git a/src/parser_lab/web/templates/collection.html b/src/parser_lab/web/templates/collection.html new file mode 100644 index 0000000..1e59203 --- /dev/null +++ b/src/parser_lab/web/templates/collection.html @@ -0,0 +1,7 @@ +
Parser Lab / Коллекции
{{ 'ПРЕДПРОСМОТР · ЧЕРНОВИК' if preview else 'УЧЕБНАЯ КОЛЛЕКЦИЯ' }}

{{ publication.title }}

{{ publication.description }}

{{ 'Черновик' if preview else 'Версия ' ~ publication.version }}
+
{{ result.total }} {{ 'запись' if detail else 'записей' }}{{ kind_label }}
+{% if not detail and (view.search_pointer or view.category_pointer) %}
{% if view.search_pointer %}{% endif %}{% if view.category_pointer %}{% endif %}Сбросить
{% endif %} +{{ fragment|safe }} +{% if message %}{% endif %} +{% if live and not detail %} +{% elif not detail %}{% else %}← Все записи{% endif %}
\ No newline at end of file diff --git a/src/parser_lab/web/templates/error.html b/src/parser_lab/web/templates/error.html new file mode 100644 index 0000000..cd00659 --- /dev/null +++ b/src/parser_lab/web/templates/error.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block content %}

Страница недоступна

{{ message }}

На главную
{% endblock %} \ No newline at end of file diff --git a/src/parser_lab/web/templates/footer.html b/src/parser_lab/web/templates/footer.html new file mode 100644 index 0000000..f621ee7 --- /dev/null +++ b/src/parser_lab/web/templates/footer.html @@ -0,0 +1 @@ +
Parser Lab · Учебный практикумТаблицы / Сообщения / Анкеты
\ No newline at end of file diff --git a/src/parser_lab/web/templates/header.html b/src/parser_lab/web/templates/header.html new file mode 100644 index 0000000..5a79371 --- /dev/null +++ b/src/parser_lab/web/templates/header.html @@ -0,0 +1 @@ +
[ ]Parser LabПрактикум по работе с даннымиКабинет преподавателя ↗
\ No newline at end of file diff --git a/src/parser_lab/web/templates/home.html b/src/parser_lab/web/templates/home.html new file mode 100644 index 0000000..bd303ea --- /dev/null +++ b/src/parser_lab/web/templates/home.html @@ -0,0 +1,2 @@ +{% extends 'base.html' %}{% block content %} +
PARSER LAB / ПРАКТИКУМ

Учебные коллекции данных

Материалы для занятий по веб-парсингу и подготовке данных к машинному обучению.

ТаблицыКарточкиСообщенияАнкеты

Студентам

Откройте коллекцию по ссылке из задания. Изучите страницы, соберите записи и сохраните результат для анализа. Регистрация не нужна.

Преподавателю

Загрузите CSV, JSON или JSONL, настройте отображение и опубликуйте коллекцию. Ссылки на готовые страницы находятся в кабинете.

Открыть кабинет →
{% endblock %} diff --git a/src/parser_lab/web/templates/login.html b/src/parser_lab/web/templates/login.html new file mode 100644 index 0000000..68fc4cf --- /dev/null +++ b/src/parser_lab/web/templates/login.html @@ -0,0 +1,3 @@ +{% extends 'base.html' %}{% block content %} +
{% endblock %} diff --git a/src/parser_lab/web/templates/public.html b/src/parser_lab/web/templates/public.html new file mode 100644 index 0000000..d160b86 --- /dev/null +++ b/src/parser_lab/web/templates/public.html @@ -0,0 +1 @@ +{% extends 'base.html' %}{% block content %}{% include 'collection.html' %}{% if live %}{% endif %}{% endblock %} \ No newline at end of file diff --git a/src/parser_lab/web/templates/records.html b/src/parser_lab/web/templates/records.html new file mode 100644 index 0000000..d62e1e7 --- /dev/null +++ b/src/parser_lab/web/templates/records.html @@ -0,0 +1,9 @@ +
+{% if not items %}
По вашему запросу ничего не найдено.
{% elif kind == 'table' and not detail %} +
{% for field in items[0].fields %}{% endfor %}{% for record in items %}{% for field in record.fields %}{% endfor %}{% endfor %}
№{{ field.label }}
{{ record.id }}{{ field.text }}{% if base %}↗{% endif %}
+{% else %}
{% for record in items %}
+
{{ 'УЧАСТНИК' if kind == 'questionnaire' else 'ЗАПИСЬ' }} {{ record.id }}{% if base and not detail %}Открыть ↗{% endif %}
+{% if kind == 'messages' %}
{{ (record.roles.get('author','А') or 'А')[:1] }}
{% if record.role_fields.get('author') %}{% set f=record.role_fields.author %}{{ f.text }}{% else %}Анонимный автор{% endif %}{% if record.role_fields.get('date') %}{% set f=record.role_fields.date %}
{{ f.text }}
{% endif %}
{% if record.role_fields.get('text') %}{% set f=record.role_fields.text %}

{{ f.text }}

{% endif %} +{% elif record.role_fields.get('title') %}{% set f=record.role_fields.title %}

{{ f.text }}

{% endif %} +
{% for field in record.fields %}
{{ field.label }}
{{ field.text }}
{% endfor %}
{% endfor %}
{% endif %}
+ diff --git a/tests/browser_server.py b/tests/browser_server.py new file mode 100644 index 0000000..883543d --- /dev/null +++ b/tests/browser_server.py @@ -0,0 +1,19 @@ +"""Isolated application fixture used by test_browser.py, never production.""" +import os +from pathlib import Path +import uvicorn +from parser_lab.app import create_app +from parser_lab.config import Settings +from parser_lab.views import default_view + +port=int(os.environ['LAB_TEST_PORT']) +app=create_app(Settings(Path(os.environ['LAB_TEST_DATA']),'teacher','test-browser-password', + 'browser-tests-only-secret-123456789',False,(f'http://127.0.0.1:{port}',))) +records=[{'name':f'Person {n:03}','text':'A walk' if n%3==0 else 'Rest','score':n,'secret':'DO-NOT-PUBLISH'} for n in range(1,52)] +d=app.state.repo.create('Browser study',b'[]','fixture.json',records);d['slug']='browser-study' +for kind in ('table','cards','messages','questionnaire'): + v=default_view(kind,['/name','/text','/score']) + v.update(title_pointer='/name',text_pointer='/text',author_pointer='/name',search_pointer='/text',category_pointer='/text') + d['views'][kind]=v +d=app.state.repo.save(d,1);app.state.repo.publish(d['id'],d['revision']) +uvicorn.run(app,host='127.0.0.1',port=port,access_log=False) diff --git a/tests/test_admin_http.py b/tests/test_admin_http.py new file mode 100644 index 0000000..fd56aac --- /dev/null +++ b/tests/test_admin_http.py @@ -0,0 +1,138 @@ +from bs4 import BeautifulSoup +from test_web import env,login + + +def headers(app,client): + return {'origin':'http://testserver','x-csrf-token':app.state.auth.csrf(client.cookies['lab_session'])} + + +def test_import_configure_preview_publish_and_conflict(env): + app,c=env;login(c);h=headers(app,c) + files={'file':('study.csv',b'name,score\nAlice,7\nBob,5','text/csv')} + assert c.post('/api/admin/inspect',files=files).status_code==403 + inspection=c.post('/api/admin/inspect',files=files,headers=h) + assert inspection.status_code==200 and inspection.json()['count']==2 + d=c.post('/api/admin/import',files=files,data={'title':'Study'},headers=h).json() + from parser_lab.views import default_view + config={'revision':d['revision'],'title':'Study','slug':d['slug'],'description':'', 'views':{'table':default_view('table',['/name','/score'])}} + preview=c.post(f"/api/admin/datasets/{d['id']}/preview?kind=table",json=config,headers=h) + assert preview.status_code==200 and 'Alice' in preview.json()['html'] + assert app.state.repo.get(d['id'])['views']=={} + saved=c.put(f"/api/admin/datasets/{d['id']}",json=config,headers=h) + assert saved.status_code==200 + assert c.put(f"/api/admin/datasets/{d['id']}",json=config,headers=h).status_code==409 + published=c.post(f"/api/admin/datasets/{d['id']}/publish",json={'revision':saved.json()['revision']},headers=h) + assert published.status_code==200 + url=f"/interactive/{d['slug']}/v/1/table" + page=c.get(url);assert page.status_code==200 and 'Alice' in page.text + assert '/rio/' not in page.text + assert c.get(url+'?fragment=1').json()['total']==2 + + +def test_api_rejects_unauthorized_and_bad_config(env): + app,c=env + assert c.post('/api/admin/inspect',headers={'origin':'http://testserver'}).status_code==401 + login(c);h=headers(app,c) + d=app.state.repo.create('Test',b'{}','a.json',[{'name':'Alice'}]) + response=c.put(f"/api/admin/datasets/{d['id']}",json={'views':{'table':{'columns':'bad'}},'revision':1},headers=h) + assert response.status_code==400 + assert app.state.repo.get(d['id'])['revision']==1 + + +def test_replacement_preserves_published_snapshot(env): + app,c=env;login(c);h=headers(app,c) + from parser_lab.views import default_view + d=app.state.repo.create('Test',b'name\nAlice','a.csv',[{'name':'Alice'}]) + d['views']={'table':default_view('table',['/name'])};d=app.state.repo.save(d,1);p=app.state.repo.publish(d['id'],d['revision']) + data={'replace_id':d['id'],'revision':str(d['revision'])} + replaced=c.post('/api/admin/import',files={'file':('b.csv',b'name\nBob','text/csv')},data=data,headers=h) + assert replaced.status_code==200 + assert app.state.repo.get(d['id'])['views']=={} + assert app.state.repo.publication(p['slug'],1)['records']==[{'name':'Alice'}] + + +def test_publish_rejects_malformed_payload_and_client_cannot_replace_records(env): + app,c=env;login(c);h=headers(app,c) + d=app.state.repo.create('Test',b'{}','a.json',[{'name':'Alice'}]) + for payload in ([],{'revision':None},{'revision':True}): + assert c.post(f"/api/admin/datasets/{d['id']}/publish",json=payload,headers=h).status_code==400 + payload={'revision':1,'views':{},'records':[{'name':'Injected'}],'id':'elsewhere'} + assert c.put(f"/api/admin/datasets/{d['id']}",json=payload,headers=h).status_code==200 + assert app.state.repo.get(d['id'])['records']==[{'name':'Alice'}] + + +def test_example_questionnaires_import_without_manual_paths(env): + from pathlib import Path + app,c=env;login(c);h=headers(app,c) + raw=(Path(__file__).parents[1]/'examples/data/questionnaires.json').read_bytes() + files={'file':('anything.json',raw,'application/json')} + inspected=c.post('/api/admin/inspect',files=files,headers=h).json() + assert inspected['count']==2 + assert inspected['record_pointer']=='/participants' + assert inspected['choose_record_level'] is False + response=c.post('/api/admin/import',files=files,headers=h) + assert response.status_code==200 + d=app.state.repo.get(response.json()['id']) + assert [r['participant'] for r in d['records']]==['A01','A02'] + assert d['views']['questionnaire']['questions_pointer']=='/answers' + assert d['views']['questionnaire']['title_pointer']=='/participant' + assert c.post(f"/api/admin/datasets/{d['id']}/publish",json={'revision':d['revision']},headers=h).status_code==200 + soup=BeautifulSoup(c.get(f"/html/{d['slug']}/v/1/questionnaire").text,'html.parser') + assert len(soup.select('.record'))==2 + assert [x.text for x in soup.select('.field-pair dd')]==['8','Прогулка','true','5','null','false'] + assert len(soup.select('.field-pair dt'))==6 + explicit=c.post('/api/admin/import',files=files,data={'root_confirmed':'1'},headers=h) + assert len(app.state.repo.get(explicit.json()['id'])['records'])==1 + + +def test_existing_wrong_level_repair_preserves_publication_and_checks_revision(env): + from pathlib import Path + from parser_lab.importing import read_upload,ImportOptions + from parser_lab.views import default_view + app,c=env;login(c);h=headers(app,c) + raw=(Path(__file__).parents[1]/'examples/data/questionnaires.json').read_bytes() + d=app.state.repo.create('Original title',raw,'questionnaires.json',read_upload(raw,'q.json',ImportOptions()).records) + d['views']={'table':default_view('table',['/description'])};d=app.state.repo.save(d,1) + original=app.state.repo.publish(d['id'],d['revision']) + url=f"/api/admin/datasets/{d['id']}/configure-questionnaire" + assert c.post(url,json={'revision':1},headers=h).status_code==409 + assert len(app.state.repo.get(d['id'])['records'])==1 + response=c.post(url,json={'revision':d['revision']},headers=h) + assert response.status_code==200 + fixed=app.state.repo.get(d['id']) + assert len(fixed['records'])==2 and fixed['title']=='Original title' + assert fixed['views']['questionnaire']['question_pointer']=='/question' + assert app.state.repo.publication(d['slug'],1)==original + assert (app.state.repo.root/'sources'/fixed['source']).read_bytes()==raw + response=c.post(f"/api/admin/datasets/{d['id']}/preview?kind=questionnaire",json=fixed,headers=h).json() + assert response['errors']==[] and 'A01' in response['html'] and 'Прогулка' in response['html'] + + +def test_ambiguous_questionnaire_wrapper_requires_choice(env): + import json + app,c=env;login(c);h=headers(app,c) + rows=[{'participant':'A','answers':[{'question':'Q','answer':0}]}] + raw=json.dumps({'first':rows,'second':rows}).encode() + files={'file':('ambiguous.json',raw,'application/json')} + inspected=c.post('/api/admin/inspect',files=files,headers=h).json() + assert inspected['choose_record_level'] and inspected['count']==1 + assert c.post('/api/admin/import',files=files,headers=h).status_code==400 + d=c.post('/api/admin/import',files=files,data={'pointer':'/second'},headers=h).json() + assert app.state.repo.get(d['id'])['views']['questionnaire']['answer_pointer']=='/answer' + + +def test_questionnaire_rejects_array_as_question(env): + from pathlib import Path + from parser_lab.importing import read_upload,ImportOptions + from parser_lab.views import default_view + app,c=env;login(c);h=headers(app,c) + raw=(Path(__file__).parents[1]/'examples/data/questionnaires.json').read_bytes() + records=read_upload(raw,'q.json',ImportOptions()).records + d=app.state.repo.create('Wrong level',raw,'q.json',records) + view=default_view('questionnaire',['/description','/participants']) + view.update(questionnaire_mode='pairs',questions_pointer='/participants',question_pointer='/answers',answer_pointer='/answers') + config={'revision':1,'views':{'questionnaire':view}} + preview=c.post(f"/api/admin/datasets/{d['id']}/preview?kind=questionnaire",json=config,headers=h).json() + assert preview['errors'] and preview['html']=='' + d['views']=config['views'];d=app.state.repo.save(d,1) + assert c.post(f"/api/admin/datasets/{d['id']}/publish",json={'revision':d['revision']},headers=h).status_code==400 diff --git a/tests/test_browser.py b/tests/test_browser.py new file mode 100644 index 0000000..757a1c0 --- /dev/null +++ b/tests/test_browser.py @@ -0,0 +1,258 @@ +import os +import sys +import socket +import subprocess +import time +from pathlib import Path +import requests +import pytest +from playwright.sync_api import sync_playwright,expect + +pytestmark=pytest.mark.skipif(os.environ.get('LAB_BROWSER_TESTS')!='1',reason='Enable LAB_BROWSER_TESTS=1 for real browser tests') +ROOT=Path(__file__).resolve().parents[1] + +@pytest.fixture(scope='module') +def live(tmp_path_factory): + folder=tmp_path_factory.mktemp('live-server') + with socket.socket() as sock: + sock.bind(('127.0.0.1',0));port=sock.getsockname()[1] + env=dict(os.environ,LAB_TEST_PORT=str(port),LAB_TEST_DATA=str(folder/'data'),PYTHONPATH=str(ROOT/'src'),PYTHONIOENCODING='utf-8') + with (folder/'server.log').open('w',encoding='utf-8') as log: + process=subprocess.Popen([sys.executable,str(ROOT/'tests/browser_server.py')],env=env,stdout=log,stderr=log) + base=f'http://127.0.0.1:{port}' + try: + deadline=time.monotonic()+25 + while time.monotonic()e.getBoundingClientRect().right <= e.closest(".editor-layout").getBoundingClientRect().right + 1') + page.screenshot(path=str(folder/'editor-desktop.png'),full_page=True) + # Live edits have not changed the public snapshot. + response=page.request.get(base+'/html/browser-study/v/1/table') + assert response.ok and 'Имя участника' not in response.text() + page.set_viewport_size({'width':390,'height':844}) + page.wait_for_function('document.documentElement.scrollWidth <= innerWidth+1',timeout=5000) + page.screenshot(path=str(folder/'editor-mobile.png'),full_page=True) + page.once('dialog',lambda dialog:dialog.accept()) + page.get_by_role('link',name='Мои коллекции',exact=True).click() + expect(page.get_by_text('Коллекции данных',exact=True)).to_be_visible() + page.wait_for_function('document.documentElement.scrollWidth <= innerWidth+1') + page.screenshot(path=str(folder/'catalog-mobile.png'),full_page=True) + + +def test_collection_home_link_uses_http_page(page,live): + base,_=live + page.goto(base+'/interactive/browser-study/v/1/table') + expect(page.locator('.record')).to_have_count(25) + page.get_by_role('link',name='Parser Lab / Коллекции',exact=True).click() + expect(page.get_by_role('heading',name='Учебные коллекции данных',exact=True)).to_be_visible() + assert page.url==base+'/' + page.go_back() + expect(page.locator('.record')).to_have_count(25) + + +def test_logout_opens_http_login_page(page,live): + base,_=live + page.goto(base+'/login') + page.get_by_label('Логин',exact=True).fill('teacher') + page.get_by_label('Пароль',exact=True).fill('test-browser-password') + page.get_by_role('button',name='Войти в кабинет').click() + expect(page.get_by_text('Коллекции данных',exact=True)).to_be_visible() + press(page,'Выйти',page.get_by_label('Пароль',exact=True)) + assert page.url==base+'/login' + page.goto(base+'/admin') + expect(page.get_by_label('Пароль',exact=True)).to_be_visible() + + +@pytest.mark.parametrize('width',[1440,390]) +def test_html_interactive_visual_parity(page,live,width): + base,folder=live + page.set_viewport_size({'width':width,'height':1000}) + selectors=['.topbar','.brand','.public-main','.page-heading h1','.version-badge','.collection-meta','.filters','.filters input','.filters select','.record:first-child'] + def metrics(): + return page.evaluate('''selectors=>Object.fromEntries(selectors.map(selector=>{ + const e=document.querySelector(selector);if(!e)return [selector,null]; + const r=e.getBoundingClientRect(),s=getComputedStyle(e); + return [selector,{x:r.x,y:r.y,width:r.width,height:r.height,font:s.fontFamily,size:s.fontSize,color:s.color,background:s.backgroundColor}]; + }))''',selectors) + page.goto(base+'/html/browser-study/v/1/table') + page.evaluate('document.fonts.ready') + expected=metrics() + page.screenshot(path=str(folder/f'html-parity-{width}.png')) + page.goto(base+'/interactive/browser-study/v/1/table') + expect(page.locator('.record')).to_have_count(25) + page.evaluate('document.fonts.ready') + actual=metrics() + page.screenshot(path=str(folder/f'interactive-parity-{width}.png')) + for selector in selectors: + assert actual[selector] is not None,selector + for key in (('x','y','width') if selector=='.public-main' else ('x','y','width','height')): + assert abs(actual[selector][key]-expected[selector][key])<=2,(selector,key,actual[selector],expected[selector]) + for key in ('font','size','color','background'): + assert actual[selector][key]==expected[selector][key],(selector,key) + page.get_by_label('Поиск в коллекции',exact=True).fill('walk') + expect(page.locator('.record')).to_have_count(17) + page.get_by_role('link',name='Сбросить',exact=True).click() + expect(page.locator('.record')).to_have_count(25) + page.get_by_label('Категория',exact=True).select_option(label='Rest') + expect(page.locator('.collection-meta b')).to_have_text('34') + expect(page.locator('.record')).to_have_count(25) + page.get_by_role('button',name='Загрузить ещё',exact=True).click() + expect(page.locator('.record')).to_have_count(34) + + +def test_json_pointer_and_stale_import_preview(page,live): + base,_=live + page.goto(base+'/login') + page.get_by_label('Логин',exact=True).fill('teacher') + page.get_by_label('Пароль',exact=True).fill('test-browser-password') + page.get_by_role('button',name='Войти в кабинет').click() + page.get_by_role('link',name='Загрузить данные',exact=True).click() + upload=page.locator('input[type=file]') + upload.set_input_files(str(ROOT/'examples/data/questionnaires.json')) + page.locator('#inspect').click() + expect(page.locator('#import-save')).to_be_enabled() + expect(page.get_by_label('Записи внутри JSON',exact=True)).to_have_value('/participants') + page.locator('#import-save').click() + expect(page.get_by_label('Представление',exact=True)).to_have_value('questionnaire') + expect(page.get_by_label('Структура анкеты',exact=True)).to_have_value('pairs') + expect(page.get_by_label('Массив ответов',exact=True)).to_have_value('/answers') + expect(page.get_by_label('Поле вопроса внутри ответа',exact=True)).to_have_value('/question') + expect(page.get_by_label('Поле ответа',exact=True)).to_have_value('/answer') + expect(page.locator('.editor-preview .record')).to_have_count(2) + expect(page.locator('.editor-preview .field-pair dd')).to_have_text(['8','Прогулка','true','5','null','false']) + expect(page.locator('.editor-preview h2')).to_have_text(['A01','A02']) + press(page,'Сохранить черновик',page.get_by_text('Черновик сохранён',exact=True)) + page.get_by_role('link',name='Импорт файла',exact=True).click() + upload.set_input_files({'name':'valid.csv','mimeType':'text/csv','buffer':b'id,value\n1,2'}) + page.locator('#inspect').click() + expect(page.locator('#import-save')).to_be_enabled() + upload.set_input_files({'name':'broken.json','mimeType':'application/json','buffer':b'{'}) + expect(page.locator('#import-save')).to_be_disabled() + expect(page.locator('#import-result .record')).to_have_count(0) + page.locator('#inspect').click() + expect(page.locator('#status')).to_have_class('error') + expect(page.locator('#import-save')).to_be_disabled() + + +def test_dynamic_page_two_starts_from_requested_page(page,live): + base,_=live + page.goto(base+'/interactive/browser-study/v/1/table?page=2') + expect(page.locator('.record').first).to_have_attribute('data-record-id','26') + page.get_by_role('button',name='Загрузить ещё',exact=True).click() + expect(page.locator('.record')).to_have_count(26) + expect(page.locator('.record').last).to_have_attribute('data-record-id','51') + + +def test_repair_legacy_questionnaire_in_browser(page,live): + base,_=live + page.goto(base+'/login') + page.get_by_label('Логин',exact=True).fill('teacher') + page.get_by_label('Пароль',exact=True).fill('test-browser-password') + page.get_by_role('button',name='Войти в кабинет').click() + csrf=page.locator('meta[name=csrf-token]').get_attribute('content') + response=page.request.post(base+'/api/admin/import',headers={'origin':base,'x-csrf-token':csrf},multipart={'file':{'name':'questionnaires.json','mimeType':'application/json','buffer':(ROOT/'examples/data/questionnaires.json').read_bytes()},'root_confirmed':'1','title':'Legacy questionnaire'}) + assert response.ok + page.goto(base+'/admin/datasets/'+response.json()['id']) + page.get_by_role('button',name='Настроить анкеты автоматически',exact=True).click() + expect(page.locator('.editor-preview .record')).to_have_count(2) + expect(page.locator('.editor-preview .field-pair dd')).to_have_text(['8','Прогулка','true','5','null','false']) + page.reload() + expect(page.locator('.editor-preview h2')).to_have_text(['A01','A02']) + page.get_by_role('button',name='Опубликовать новую версию',exact=True).click() + expect(page.get_by_text('Версия 1 · Доступна',exact=True)).to_be_visible() diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..24521d6 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,50 @@ +import pytest +from parser_lab.repository import Repository, Conflict, NotFound +from parser_lab.views import default_view, validate_view, project_record +from parser_lab.query import select_records + +def make_draft(repo): + d = repo.create('Study', b'[]', 'source.json', [{'name':'Ann','secret':'hidden'}, {'name':'Bob'}]) + d['views'] = {'table': default_view('table', ['/name'])} + return repo.save(d, d['revision']) + +def test_versions_and_conflicts(tmp_path): + repo = Repository(tmp_path) + d = make_draft(repo) + p = repo.publish(d['id'], d['revision']) + old = repo.get(d['id']) + d['title'] = 'New title' + d['records'][0]['name'] = 'Changed' + d = repo.save(d, d['revision']) + with pytest.raises(Conflict): repo.save(old, old['revision']) + assert repo.publication(p['slug'],1)['records'][0]['name'] == 'Ann' + assert repo.publish(d['id'],d['revision'])['version'] == 2 + assert Repository(tmp_path).get(d['id'])['title'] == 'New title' + repo.unpublish(p['slug'],1) + with pytest.raises(NotFound): repo.publication(p['slug'],1) + +def test_view_projection_and_pagination(): + view = default_view('table',['/name']) + rows = [{'name':str(i), 'secret':'hidden'} for i in range(51)] + assert validate_view(view,rows) == [] + p = select_records(rows,view,3) + assert p['items'][0]['id'] == '51' + assert p['total'] == 51 and not p['has_next'] + assert 'hidden' not in str(project_record(rows[0],view,'1')) + with pytest.raises(ValueError): select_records(rows,view,0) + with pytest.raises(NotFound): select_records(rows,view,4) + +def test_value_states_and_questionnaires(): + view = default_view('table',['/x']) + got = [project_record(r,view,'1')['fields'][0] for r in [{},{'x':None},{'x':False},{'x':0},{'x':''}]] + assert [v['state'] for v in got] == ['missing','null','value','value','value'] + assert [v['text'] for v in got] == ['Нет значения','null','false','0',''] + assert [v['type'] for v in got] == ['missing','null','boolean','number','string'] + q = default_view('questionnaire',['/answers']) + q.update(questionnaire_mode='pairs',questions_pointer='/answers',question_pointer='/q',answer_pointer='/a') + assert project_record({'answers':[{'q':'Sleep?','a':7}]},q,'1')['fields'][0]['text'] == '7' + +def test_typed_category_and_search(): + v=default_view('table',['/x']); v.update(search_pointer='/x',category_pointer='/x') + assert select_records([{'x':'HELLO'},{'x':'other'}],v,1,'hello')['total']==1 + assert select_records([{'x':0},{'x':'0'}],v,1,'','0')['total']==1 diff --git a/tests/test_importing.py b/tests/test_importing.py new file mode 100644 index 0000000..636efce --- /dev/null +++ b/tests/test_importing.py @@ -0,0 +1,35 @@ +import pytest +from parser_lab.importing import read_upload, ImportOptions, ImportErrorDetail +from parser_lab.paths import resolve_pointer,field_summary + +def test_csv_preserves_strings_and_quotes(): + r = read_upload('id;answer\n001;"a\nb"\n002;\n'.encode(), 'a.csv', ImportOptions(delimiter=';')) + assert r.records == [{'id': '001', 'answer': 'a\nb'}, {'id': '002', 'answer': ''}] + +def test_json_preserves_types_and_nested_selection(): + r = read_upload(b'{"items":[{"x":null},{"x":false},{"x":0},{"x":""},{}]}', 'a.json', ImportOptions(record_pointer='/items')) + assert r.records == [{'x': None}, {'x': False}, {'x': 0}, {'x': ''}, {}] + assert resolve_pointer({'a/b': {'~c': 0}}, '/a~1b/~0c') == (True, 0) + assert resolve_pointer({}, '/x') == (False, None) + +@pytest.mark.parametrize('data,name', [(b'a,a\n1,2','a.csv'), (b'a,b\n1,2,3','a.csv'), (b'{"x":1,"x":2}','a.json'), (b'{"x":NaN}','a.json'), (b'[1,2]','a.json'), (b'[]','a.json')]) +def test_invalid_inputs_rejected(data, name): + with pytest.raises(ImportErrorDetail): read_upload(data, name, ImportOptions()) + +def test_jsonl_reports_line_and_blanks(): + assert read_upload(b'{"a":1}\n\n{"b":2}', 'a.jsonl', ImportOptions()).warnings + with pytest.raises(ImportErrorDetail, match='3'): + read_upload(b'{"a":1}\n\nBAD', 'a.jsonl', ImportOptions()) + +def test_encoding_limits_and_fields(): + assert read_upload('имя;возраст\nОля;20'.encode('cp1251'), 'a.csv', ImportOptions(encoding='cp1251', delimiter=';')).records[0]['имя'] == 'Оля' + assert '/later' in read_upload(b'[{}, {"later":1}]','a.json',ImportOptions()).fields + with pytest.raises(ImportErrorDetail): read_upload(b'12345','a.json',ImportOptions(), max_bytes=4) + with pytest.raises(ImportErrorDetail): read_upload(b'[{},{}]','a.json',ImportOptions(), max_records=1) + with pytest.raises(ImportErrorDetail): read_upload(('[' + '{"x":'*21 + '0' + '}'*21 + ']').encode(),'a.json',ImportOptions()) + +def test_field_tree_and_missing_counts(): + summary=field_summary([{'profile':{'age':20},'a/b':0},{'profile':{}},{}]) + assert summary['/profile/age']['missing']==2 + assert summary['/profile/age']['parent']=='/profile' + assert summary['/a~1b']['label']=='a/b' diff --git a/tests/test_parser_fields.py b/tests/test_parser_fields.py new file mode 100644 index 0000000..337e39f --- /dev/null +++ b/tests/test_parser_fields.py @@ -0,0 +1,16 @@ +import sys +from pathlib import Path +sys.path.insert(0,str(Path(__file__).resolve().parents[1]/'examples/parsers')) +from common import extract +from parser_lab.views import project_record,default_view +from parser_lab.web import records_fragment + +def test_message_roles_and_question_labels_are_exported(): + v=default_view('messages',['/extra']);v.update(text_pointer='/body',author_pointer='/author') + r=project_record({'body':'Visible message','author':'Visible author','extra':'Additional'},v,'1') + exported=extract(records_fragment([r],'messages'))[0] + assert exported['roles']['text']['text']=='Visible message' + assert exported['roles']['author']['text']=='Visible author' + v=default_view('questionnaire',['/x']);v['columns'][0]['label']='Hours of sleep?' + exported=extract(records_fragment([project_record({'x':8},v,'1')],'questionnaire'))[0] + assert exported['fields'][0]['label']=='Hours of sleep?' diff --git a/tests/test_password_config.py b/tests/test_password_config.py new file mode 100644 index 0000000..5ce89c7 --- /dev/null +++ b/tests/test_password_config.py @@ -0,0 +1,67 @@ +import os +import subprocess +import sys +from dataclasses import replace + +import pytest + +from parser_lab.auth import Auth, Unauthorized +from parser_lab.config import Settings +from parser_lab.repository import Repository +from parser_lab import cli + + +def settings_from_env(monkeypatch, tmp_path): + monkeypatch.setenv('LAB_ADMIN_USER', 'teacher') + monkeypatch.setenv('LAB_ADMIN_PASSWORD', "мой пароль $ # ' \\ test") + monkeypatch.setenv('LAB_SECRET', 'test-secret-at-least-32-characters') + monkeypatch.setenv('LAB_DATA_DIR', str(tmp_path / 'data')) + monkeypatch.delenv('LAB_ADMIN_HASH', raising=False) + return Settings.from_env() + + +def test_password_env_login_restart_and_change(monkeypatch, tmp_path): + settings = settings_from_env(monkeypatch, tmp_path) + password = os.environ['LAB_ADMIN_PASSWORD'] + assert password not in repr(settings) + repo = Repository(settings.data_dir) + auth = Auth(settings, repo) + token = auth.login('teacher', password, 'client') + assert Auth(Settings.from_env(), repo).check(token) == token + with pytest.raises(Unauthorized): + auth.login('teacher', password + 'x', 'client') + changed = Auth(replace(settings, password='replacement-password'), repo) + with pytest.raises(Unauthorized): + changed.check(token) + with pytest.raises(Unauthorized): + changed.login('teacher', password, 'client') + assert changed.login('teacher', 'replacement-password', 'client') + + +@pytest.mark.parametrize('password', ['', 'short']) +def test_invalid_env_password(monkeypatch, tmp_path, password): + settings_from_env(monkeypatch, tmp_path) + monkeypatch.setenv('LAB_ADMIN_PASSWORD', password) + with pytest.raises(ValueError, match='LAB_ADMIN_PASSWORD'): + Settings.from_env() + + +def test_init_env_plaintext_password_loads_with_uv(monkeypatch, tmp_path): + path = tmp_path / '.env' + password = "тест $HOME ${UNKNOWN} # ' \\ end" + monkeypatch.setattr(sys, 'argv', ['cli', 'init-env', '--path', str(path)]) + monkeypatch.setattr('builtins.input', lambda _: 'teacher') + monkeypatch.setattr(cli.getpass, 'getpass', lambda _: password) + cli.main() + text = path.read_text(encoding='utf-8') + assert 'LAB_ADMIN_PASSWORD=' in text and 'LAB_ADMIN_HASH=' not in text + env = {k: v for k, v in os.environ.items() if not k.startswith('LAB_')} + env['EXPECTED_PASSWORD'] = password + result = subprocess.run( + ['uv', 'run', '--no-project', '--env-file', str(path), sys.executable, '-c', + 'import os; assert os.environ["LAB_ADMIN_PASSWORD"] == os.environ["EXPECTED_PASSWORD"]'], + env=env, capture_output=True, text=True, + ) + assert result.returncode == 0, result.stderr + with pytest.raises(SystemExit, match='уже существует'): + cli.main() diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..a9ffe8f --- /dev/null +++ b/tests/test_web.py @@ -0,0 +1,99 @@ +import re +from fastapi.testclient import TestClient +from bs4 import BeautifulSoup +import pytest +from parser_lab.app import create_app +from parser_lab.config import Settings +from parser_lab.views import default_view + +@pytest.fixture +def env(tmp_path): + settings=Settings(tmp_path,'teacher','test-password','test-secret-long-enough-123456789',False,('http://testserver',)) + app=create_app(settings) + with TestClient(app) as client: + yield app,client + +def login(client): + page=client.get('/login') + csrf=BeautifulSoup(page.text,'html.parser').select_one('[name=csrf]')['value'] + return client.post('/login',data={'username':'teacher','password':'test-password','csrf':csrf},headers={'origin':'http://testserver'},follow_redirects=False) + +def test_auth_and_csrf(env): + app,c=env + assert c.get('/healthz').status_code==200 + assert c.get('/admin',follow_redirects=False).status_code==303 + assert c.post('/login',data={'username':'teacher','password':'test-password'}).status_code==403 + assert login(c).status_code==303 + assert c.get('/admin').status_code==200 + assert c.post('/logout',headers={'origin':'https://bad.example'}).status_code==403 + +def test_admin_is_server_rendered_for_headless_browser(env): + app,c=env + login(c) + page=c.get('/admin',headers={'user-agent':'Mozilla/5.0 HeadlessChrome/140.0.0.0 Safari/537.36'}) + assert page.status_code==200 and 'Коллекции данных' in page.text + assert '/rio/' not in page.text + +def test_logout_expiry_rate_limit_and_replacement_credentials(env): + from parser_lab.auth import Auth, Unauthorized,RateLimited + from dataclasses import replace + app,c=env; auth=app.state.auth + token=auth.login('teacher','test-password','one') + assert auth.check(token)==token + with app.state.repo.connect() as db: db.execute('UPDATE sessions SET expires=0') + with pytest.raises(Unauthorized): auth.check(token) + for _ in range(5): + with pytest.raises(Unauthorized): auth.login('teacher','bad','two') + with pytest.raises(RateLimited): auth.login('teacher','test-password','two') + login(c); token=c.cookies['lab_session'] + assert c.post('/logout',data={'csrf':auth.csrf(token)},headers={'origin':'http://testserver'},follow_redirects=False).status_code==303 + with pytest.raises(Unauthorized): auth.check(token) + token=auth.login('teacher','test-password','three') + changed=Auth(replace(auth.settings,secret='changed'),app.state.repo) + with pytest.raises(Unauthorized): changed.check(token) + +def test_oversized_upload_rejected_before_parsing(env): + app,c=env;login(c) + response=c.post('/api/admin/import',content=b'x',headers={'origin':'http://testserver','x-csrf-token':app.state.auth.csrf(c.cookies['lab_session']),'content-length':str(30*1024*1024)}) + assert response.status_code==413 + +def test_html_snapshot_and_escaping(env): + app,c=env; repo=app.state.repo + d=repo.create('Исследование',b'[]','a.json',[{'text':'','secret':'HIDDEN'} for _ in range(51)]) + d['views']={'table':default_view('table',['/text'])}; d=repo.save(d,1); p=repo.publish(d['id'],d['revision']) + base=f"/html/{p['slug']}/v/1/table" + page=c.get(base); soup=BeautifulSoup(page.text,'html.parser') + assert page.status_code==200 and len(soup.select('tbody tr'))==25 + assert soup.select_one('tbody td.field').text.strip()=='' + assert not soup.select('script') and 'HIDDEN' not in page.text + assert len(BeautifulSoup(c.get(base+'?page=3').text,'html.parser').select('tbody tr'))==1 + assert c.get(base+'?page=0').status_code==400 + assert c.get(base+'?page=no').status_code==400 + assert c.get(base+'?page=4').status_code==404 + assert 'HIDDEN' not in c.get(base+'/records/1').text + assert c.get(f"/preview/{d['id']}/table",follow_redirects=False).status_code==303 + repo.unpublish(p['slug'],1) + assert c.get(base).status_code==404 + assert c.get(f"/interactive/{p['slug']}/v/1/table").status_code==404 + +def test_preview_filters_match_publication(env): + app,c=env;login(c);repo=app.state.repo + d=repo.create('Filter',b'[]','a.json',[{'name':'Alice'},{'name':'Bob'}]) + view=default_view('table',['/name']);view['search_pointer']='/name' + d['views']={'table':view};d=repo.save(d,1) + response=c.get(f"/preview/{d['id']}/table?q=Alice") + assert BeautifulSoup(response.text,'html.parser').select_one('.field').text=='Alice' + assert len(BeautifulSoup(response.text,'html.parser').select('.record'))==1 + + +def test_all_pages_share_favicon(env): + app,client=env + static=client.get('/static/favicon.png') + assert static.status_code==200 and static.content.startswith(b'\x89PNG\r\n\x1a\n') + repo=app.state.repo + d=repo.create('Icons',b'[]','a.json',[{'name':'Alice'}]) + d['views']={'table':default_view('table',['/name'])};d=repo.save(d,1);repo.publish(d['id'],d['revision']) + login(client) + for path in ('/','/login','/admin',f"/html/{d['slug']}/v/1/table",f"/interactive/{d['slug']}/v/1/table"): + page=BeautifulSoup(client.get(path).text,'html.parser') + assert page.select_one('link[rel=icon]')['href']=='/static/favicon.png' diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..b965690 --- /dev/null +++ b/uv.lock @@ -0,0 +1,547 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/43/bb8b6e8708d49a5ab36781333af092d9f483b198a2710d01281204640055/argon2_cffi_bindings-26.1.0.tar.gz", hash = "sha256:63505c71542a44b68b1e38060450fb006404170da375feb31af153e7f9c6205d", size = 1790807, upload-time = "2026-08-20T07:44:22.492Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/d2/0ae991f1b2181e5be49007c574710a800ad36c2978683addb3e67c474e55/argon2_cffi_bindings-26.1.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:21ca0396fe5ec995dd54431c32698189666f9224810acfa752e50d2bd94d9df2", size = 25521, upload-time = "2026-08-20T07:32:43.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e4/ad91d8297638aa2258aad4501c306aca99480dfe76ccd638173fa3702db9/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78de2d65e0b9ea7ce9d1b1c3e87297b2d7305a02c266ee2a2d6910daddd7ee69", size = 27177, upload-time = "2026-08-20T07:32:44.158Z" }, + { url = "https://files.pythonhosted.org/packages/6f/86/5363df11b86d02cf3662208e7406496327649cc90eb365bf6f4e8a54a41f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27f1821903e2ceadcb88ec2b45ef190897b7682449c772f4d9b53e42c520cf29", size = 26597, upload-time = "2026-08-20T07:32:45.172Z" }, + { url = "https://files.pythonhosted.org/packages/f4/b5/a14dcc592652347dad23ee93b278a4da5d2a25c9ed3ebd10d68eea823a4f/argon2_cffi_bindings-26.1.0-cp310-abi3-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d88e5f7e60f28ae0b0cc6b2f16c43e87cd642a196a86f85e0d8bb6fe016fc16d", size = 27403, upload-time = "2026-08-20T07:32:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/b4a20d4902af7f796390bf9245ff83c5217dfa7367efa1d14986956c482b/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:34b7d9c24a4165a2c61cc8ae11d44d48c9ce2830fb536cb7914e11fdd9962728", size = 27132, upload-time = "2026-08-20T07:32:47.13Z" }, + { url = "https://files.pythonhosted.org/packages/7e/1b/c8de358af07b1c490e0fcb863ef98e46ddb486e45567aca5a60bd68d9daa/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:224865cbbcb7a2bd1356741dff12b0134df726b6d44bb7b500df8e303cbd9e81", size = 27588, upload-time = "2026-08-20T07:32:48.087Z" }, + { url = "https://files.pythonhosted.org/packages/48/2f/7ee62a6e79f9309f9d9982d301b22a00010adb580c05c8109b94d7b33de0/argon2_cffi_bindings-26.1.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ffff613aaa9ce6236766e2fc6dc560bb5abde7a2e2416e3db1f9ae395a2b4dd4", size = 26785, upload-time = "2026-08-20T07:32:48.977Z" }, + { url = "https://files.pythonhosted.org/packages/e9/10/960d0ee93d4897741bcaf4799c697dae2d81499f66fd1ed042a7dd54c1f4/argon2_cffi_bindings-26.1.0-cp310-abi3-win32.whl", hash = "sha256:a86c069c91a747a2c4e5c51473590aeb48172fff9b2130d23729a42d98665ecb", size = 23898, upload-time = "2026-08-20T07:32:50.114Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3a/0cc14a05810e6add9bce5e87693334baa2222de5f647fa31781885b6573f/argon2_cffi_bindings-26.1.0-cp310-abi3-win_amd64.whl", hash = "sha256:2c36ff87b5dfaa477d0bd51e9d7f6abdae7c8955d2983c97419085d842154b3e", size = 25730, upload-time = "2026-08-20T07:32:51.091Z" }, + { url = "https://files.pythonhosted.org/packages/4e/db/d83cf2af140547f0b9cdaece05b2dc2dcbf991be4667331d073eff771435/argon2_cffi_bindings-26.1.0-cp310-abi3-win_arm64.whl", hash = "sha256:f9c4420a7a864fe1b86ce35befc95b8e39fb852493b81cf798671ddc265de638", size = 24478, upload-time = "2026-08-20T07:32:52.111Z" }, +] + +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659, upload-time = "2026-09-30T04:39:23.398Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780, upload-time = "2026-09-30T04:35:30.888Z" }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730, upload-time = "2026-09-30T04:35:32.286Z" }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707, upload-time = "2026-09-30T04:35:33.709Z" }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050, upload-time = "2026-09-30T04:35:35.138Z" }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345, upload-time = "2026-09-30T04:35:36.502Z" }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601, upload-time = "2026-09-30T04:35:37.919Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222, upload-time = "2026-09-30T04:35:39.544Z" }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482, upload-time = "2026-09-30T04:35:41.088Z" }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206, upload-time = "2026-09-30T04:35:42.59Z" }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190, upload-time = "2026-09-30T04:35:44.347Z" }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527, upload-time = "2026-09-30T04:35:45.915Z" }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285, upload-time = "2026-09-30T04:35:47.49Z" }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010, upload-time = "2026-09-30T04:35:48.996Z" }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125, upload-time = "2026-09-30T04:35:50.777Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486, upload-time = "2026-09-30T04:35:52.194Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734, upload-time = "2026-09-30T04:35:53.636Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815, upload-time = "2026-09-30T04:38:26.216Z" }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276, upload-time = "2026-09-30T04:38:28.032Z" }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239, upload-time = "2026-09-30T04:38:29.732Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121, upload-time = "2026-09-30T04:38:31.462Z" }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350, upload-time = "2026-09-30T04:38:33.239Z" }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430, upload-time = "2026-09-30T04:38:34.865Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612, upload-time = "2026-09-30T04:38:36.649Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083, upload-time = "2026-09-30T04:38:38.26Z" }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738, upload-time = "2026-09-30T04:38:39.81Z" }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703, upload-time = "2026-09-30T04:38:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622, upload-time = "2026-09-30T04:38:42.937Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500, upload-time = "2026-09-30T04:38:44.604Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100, upload-time = "2026-09-30T04:38:46.289Z" }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499, upload-time = "2026-09-30T04:38:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092, upload-time = "2026-09-30T04:38:49.707Z" }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363, upload-time = "2026-09-30T04:38:51.312Z" }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872, upload-time = "2026-09-30T04:39:21.828Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "greenlet" +version = "3.5.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653, upload-time = "2026-09-14T15:42:51.806Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/72/18/3fc6d951466ae9a2a688edcddde3b2e388da0a8244e0caf7117bbeb0eb95/greenlet-3.5.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:a5876d0a60355af98d535c47f6cd6eb0f8a432396dab26845d380b92f8412422", size = 295668, upload-time = "2026-09-14T14:22:33.241Z" }, + { url = "https://files.pythonhosted.org/packages/27/89/366d2af5061eeefa5012f510d95a99c8620dcc457609838db4d538820318/greenlet-3.5.6-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e85880b538e59a59f55117b81f208a6660ad5ac328aad9305f812d9b8bc67a0f", size = 611700, upload-time = "2026-09-14T15:12:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/07f133f865fd58ae593dd2bbec3144acaee9b04ffe2eb48c6e121747ceef/greenlet-3.5.6-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f0ba7c2a329d650628f4c8572fd1db29f0a59dd70a3e3e0710dcf18a35cce9d8", size = 624223, upload-time = "2026-09-14T15:20:42.459Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f2/844dc823ff2752ad049caa6b59d57e4572f9c445934b02d3518f4c67197c/greenlet-3.5.6-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee7d9da3bf493909cf811a3f038840cb34fab5ae2956b8a263919f6e289ab188", size = 629529, upload-time = "2026-09-14T15:25:06.354Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/1594f3869c57c149abdb380492529e04d4c0229b5e4d79572c5bd0aaa673/greenlet-3.5.6-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:975736b002ed080d124cf81a79cb7e05cb26d6b3f5c7a7b651c0fcce70353aa1", size = 621404, upload-time = "2026-09-14T14:35:59.027Z" }, + { url = "https://files.pythonhosted.org/packages/c0/42/b1f8dbc89a53b9e77859fc1ad1627d106fc361daa3ea4bdf43a91ebb4338/greenlet-3.5.6-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:71890d5247020c25c21a6b65202782bfc281d4e6e244842419d30e3492bb6dcc", size = 432385, upload-time = "2026-09-14T15:28:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/a2/f5/33e5c9e48178b9259fd000f8f45caa4a65036f65d3d0c06a602f570f025d/greenlet-3.5.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0616b8f878098c5681fd8f0dc92d887551717402342a70f0abcbfea5f5ad8a44", size = 1584998, upload-time = "2026-09-14T15:10:06.653Z" }, + { url = "https://files.pythonhosted.org/packages/ef/31/9b4e140bc24d0ad7927ebd651f5608b0acc2334d061748c3b6ad19085cfa/greenlet-3.5.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3dbb4596a6a4e5d47121a33ff20533a81e60f302d9e67b69909a8bc21a43f0a7", size = 1647568, upload-time = "2026-09-14T14:35:49.787Z" }, + { url = "https://files.pythonhosted.org/packages/c3/71/d79f1791f824f8ff15c2978746640467ae932a2365e0201069f7f272395f/greenlet-3.5.6-cp312-cp312-win_amd64.whl", hash = "sha256:7ac4abb3877c43af320392c664774eef6fa2cc063c79a55fc02d844a3cbe7395", size = 324203, upload-time = "2026-09-14T14:22:54.504Z" }, + { url = "https://files.pythonhosted.org/packages/63/af/42aca4d56e8cb321912203069d8d34734cb288222f10ad2ae102718cc577/greenlet-3.5.6-cp312-cp312-win_arm64.whl", hash = "sha256:301102a49120b095e72a7838792b41233975fc1c155daec6d98f81c00c9280e0", size = 308310, upload-time = "2026-09-14T14:24:03.008Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "parser-lab" +version = "1.0.0" +source = { editable = "." } +dependencies = [ + { name = "argon2-cffi" }, + { name = "fastapi" }, + { name = "jinja2" }, + { name = "python-multipart" }, + { name = "uvicorn" }, +] + +[package.dev-dependencies] +dev = [ + { name = "beautifulsoup4" }, + { name = "httpx" }, + { name = "playwright" }, + { name = "pytest" }, + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "argon2-cffi", specifier = "==25.1.0" }, + { name = "fastapi", specifier = "==0.141.1" }, + { name = "jinja2", specifier = "==3.1.6" }, + { name = "python-multipart", specifier = ">=0.0.20" }, + { name = "uvicorn", specifier = "==0.52.4" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "beautifulsoup4", specifier = "==4.15.0" }, + { name = "httpx", specifier = "==0.28.1" }, + { name = "playwright", specifier = "==1.63.0" }, + { name = "pytest", specifier = "==9.1.1" }, + { name = "requests", specifier = "==2.34.2" }, +] + +[[package]] +name = "playwright" +version = "1.63.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet" }, + { name = "pyee" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/19/dd/fbb3d34228ad753bc14464d5f2252585b73367539775a88d0a3949cf5a45/playwright-1.63.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:84c540759e8e7f01e690e197e04060f48899d37cea322299f255843273d3385d", size = 44278407, upload-time = "2026-09-15T16:49:06.045Z" }, + { url = "https://files.pythonhosted.org/packages/f5/9a/948b930b1a8c4ee869e5a139a2b7747caa06aab56a3f09a2f0abdcbda221/playwright-1.63.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fd1aa00631d44d55e56e0975bf3f3f285fac4a9fd2813183f0c12a488f1a1b24", size = 42944679, upload-time = "2026-09-15T16:49:10.039Z" }, + { url = "https://files.pythonhosted.org/packages/94/11/dc5c13fa1602371603acd461be47529c1b3513815d3a0dc98f642c291a10/playwright-1.63.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:c89fc4736502a1f0fac2c8ca5d10c0cbc1c669f1f4774a2d8507a43140e4d53f", size = 44278410, upload-time = "2026-09-15T16:49:13.861Z" }, + { url = "https://files.pythonhosted.org/packages/27/9c/103a5037789062bdab27c7dca53f3ca6b075b572ab2cd96eec825b3aec4e/playwright-1.63.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:ad21bc07516b187965a7521c5cf0df0bd657b17482eaad74335272d35a2b07de", size = 48217159, upload-time = "2026-09-15T16:49:17.404Z" }, + { url = "https://files.pythonhosted.org/packages/f3/82/3d85505284c5a210f2da6c07b8f757524e79d1fba9cfdafe1eafb766ae59/playwright-1.63.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:354e15b29503565fc598b89f16fbe070459343bef9d7498a93e304864000c6a7", size = 47902708, upload-time = "2026-09-15T16:49:21.055Z" }, + { url = "https://files.pythonhosted.org/packages/69/8d/f74ff6b52751859f4b69caf66aa7d4a3c8d6c1f0c7dc9920da103612ee39/playwright-1.63.0-py3-none-win32.whl", hash = "sha256:660c00c62639e31b16700ba5456b351ddba55bb766b7ce8261223aa34928e482", size = 38606075, upload-time = "2026-09-15T16:49:29.546Z" }, + { url = "https://files.pythonhosted.org/packages/76/eb/d6b8d92658038e260dbc7dd69fb3fdbe245aac283953de8b50b02bfe5f61/playwright-1.63.0-py3-none-win_amd64.whl", hash = "sha256:2f9a707a6c6c91157ed77bff2b8caeb04b3c8d46e70d585fc134298cbe4b5cc6", size = 38606082, upload-time = "2026-09-15T16:49:32.838Z" }, + { url = "https://files.pythonhosted.org/packages/be/75/2432b3e3c7c62103b72d4c4cc8b16a56383ada372bbb0d1278591e988f71/playwright-1.63.0-py3-none-win_arm64.whl", hash = "sha256:1e4a3a838ce22fb68ad17193fcd142a19610d9d70fb9966d2239f5dddc0cc05b", size = 34523554, upload-time = "2026-09-15T16:49:36.455Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, +] + +[[package]] +name = "pyee" +version = "13.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "soupsieve" +version = "2.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/71/c3/1b817965ac12dc002d7c9cd7dfffdd7d4fbf9b45763ef2ffe7b86ee94670/soupsieve-2.10.tar.gz", hash = "sha256:49e9380d7d2905463583bafe285e818c7366a9ed7b3aee221c1ac79c905d8bc0", size = 124337, upload-time = "2026-09-24T02:36:35.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/87/5ed59e1d0290564e2027ed066c52b059892c4637741337fc0967183c8d4d/soupsieve-2.10-py3-none-any.whl", hash = "sha256:8596eb8967d744174820280fa62b4542a2e955bfaccca73ed8a13c6eb8e9b502", size = 38019, upload-time = "2026-09-24T02:36:33.709Z" }, +] + +[[package]] +name = "starlette" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/0f/3f86e61397dd33bf2ccf28188c40db6a740658aeebbbf6e7dbc101a1f487/uvicorn-0.52.4.tar.gz", hash = "sha256:73acfee47a0b133c5de13d219492d62d8a31e935f4fe6e41a232451a15379f86", size = 100627, upload-time = "2026-08-19T06:27:41.821Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/79/4a20b54ab0491485ccd8c077db2d39187c7f12b3e15485d38a7be37c81b4/uvicorn-0.52.4-py3-none-any.whl", hash = "sha256:f86e41a149d7d05a9969337e3946a9c171c06a5d42680896daaba624aeac8da1", size = 79871, upload-time = "2026-08-19T06:27:40.36Z" }, +]