MVP сайта

This commit is contained in:
Даниил
2026-10-01 00:28:16 +10:00
commit 25f4255404
55 changed files with 3325 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
.env
.env.*
!.env.example
.git
.venv
/data/
__pycache__
*.pyc
.pytest_cache
tests
docs
*.egg-info
dist
build
HockeyTeamParse/
+9
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
.env
.env.*
!.env.example
.venv/
/data/
__pycache__/
*.pyc
.pytest_cache/
*.egg-info/
build/
dist/
HockeyTeamParse/
data/
+1
View File
@@ -0,0 +1 @@
3.12
+26
View File
@@ -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"]
+189
View File
@@ -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-образа.
+15
View File
@@ -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:
+156
View File
@@ -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). Архитектура этого документа является проектным предложением, а не утверждением о готовности перечисленных функций в старом приложении.
+210
View File
@@ -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-проба содержит `'<table'`, запрос динамической страницы возвращает Rio-оболочку. Проверить lifespan при старте и завершении.
- [ ] Запустить `python -m pytest tests/integration/test_app.py -q`, подтвердить отсутствие реализации.
- [ ] Реализовать app factory, конфигурацию и совмещение пользовательских маршрутов с `rio.App.as_fastapi()` по подтверждённому API. Проба находится в тестовом приложении, а не в рабочей выдаче.
- [ ] Повторить тест и открыть динамическую пробу в браузере: компонент отображается и кнопка обновляет значение через WebSocket. Зафиксировать версии, команду запуска и результат.
Результат: работающий общий сервер; архитектурные ограничения Rio выявлены до создания кабинета.
## Задача 2 Импорт без потери исходных значений
**Files:** создать `domain.py`, `paths.py`, `importing/{__init__,csv_reader,json_reader,service}.py`, `tests/unit/test_importing.py`, `tests/unit/test_paths.py`.
**Interfaces:** `JsonValue` — стандартное JSON-значение; `Record = dict[str, JsonValue]`; `ImportOptions(format, encoding, delimiter, record_pointer)`; `ImportResult(records, fields, warnings)`; `ImportIssue(message, line, column)`; `read_upload(data: bytes, filename: str, options: ImportOptions, settings: Settings) -> 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. Вредоносное значение `<script>alert(1)</script>` остаётся текстом, исполняемый тег не появляется. Проверить отсутствие скрытых полей на списке и деталях.
- [ ] Запустить `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 предпросмотре без входа и прямых служебных запросах. Проверить пользовательские `<script>`, HTML и URL как неисполняемые значения.
- [ ] Запустить `python -m pytest tests/integration/test_visibility.py tests/browser/test_layout.py -q`, исправить наблюдаемые проблемы и повторить только затронутые проверки.
## Задача 11 Примеры и контроль сбора данных
**Files:** создать `examples/data/{hockey_teams.csv,messages.jsonl,questionnaires.json,profiles.csv}`, `examples/parsers/{scrape_html.py,scrape_interactive.py}`, `tests/integration/test_scraping.py`.
**Interfaces:** оба CLI принимают URL опубликованного представления и путь результата; выводят JSONL с теми же видимыми полями, record_id и состоянием отсутствующих значений. Решения не монтируются как публичные assets.
- [ ] Скопировать хоккейный CSV из старого проекта без изменения источника; подготовить синтетические примеры других представлений.
- [ ] Создать проверку ожидаемых данных: экспорт обоих парсеров совпадает с проекцией исходных записей, включая порядок, дубликаты значений, missing/null и последнюю неполную страницу.
- [ ] Реализовать HTTP-парсер на requests и BeautifulSoup с переходом по ссылкам, динамический — на Playwright с ожиданием состояния/элементов, без фиксированных sleep. Проверить таймауты, HTTP-ошибку и прекращение при снятой версии.
- [ ] Выполнить `python -m pytest tests/integration/test_scraping.py -q` на локальном тестовом сервере. Сверить каждый вид в двух режимах; автоматизировать повторяющиеся варианты параметризацией.
## Задача 12 Docker и передача проекта
**Files:** создать `Dockerfile`, `compose.yaml`, `.env.example`, `.dockerignore`, `README.md`, `docs/{design-spec,implementation-plan}.md`.
- [ ] Собрать образ на закреплённой совместимой Python, установить lock-зависимости, включить локальные ресурсы, запускать непривилегированным пользователем. Порт 8080, healthcheck `/healthz`, named volume в `/data`, без исходников и секретов рабочего окружения в образе.
- [ ] Проверить `docker compose config` и `docker compose build`; затем `docker compose up -d`. Передавать тестовые секреты безопасным локальным файлом, исключённым из Git и образа.
- [ ] Через контейнер пройти импорт и публикацию, HTTP-парсер и Playwright. Выполнить `docker compose down` без `-v`, поднять снова и проверить прежние ссылки и данные.
- [ ] Проверить резервное копирование при остановленном контейнере и восстановление в отдельный пустой том, не удаляя рабочий. Зафиксировать права каталога и успешный healthcheck после восстановления.
- [ ] Документировать запуск, генерацию хеша, переменные окружения, смену пароля, reverse proxy с WebSocket, сохранность тома, обновление, резервную копию, примеры файлов и парсеров. Не обещать проверку HTTPS, если proxy фактически не запускался.
- [ ] Выполнить полный `python -m pytest -q`, сборку и контрольный пользовательский сценарий. Если Docker Engine недоступен, сохранить Docker-файлы, явно отметить непроверенную сборку и не считать Docker-критерий выполненным.
## Контроль перед завершением
- [ ] Сопоставить реализованные функции со всеми критериями спецификации, включая отсутствие утечек полей и сохранность версий.
- [ ] Проверить весь проект независимым обзором после реализации; устранить подтверждённые дефекты и повторить затронутые проверки.
- [ ] Удалить только собственные временные тестовые данные из поставки, остановить временные процессы, сохранить пользовательские данные.
- [ ] Передать путь к проекту, инструкцию запуска, проверенные команды и известные ограничения. Публикацию на внешний сервер выполнять только по отдельному запросу.
## Порядок исполнения
Задачи выполняются последовательно в этой беседе. Тесно связанные интерфейсы импорта, представлений и публикаций делают этот вариант предпочтительным для первой реализации. Делегирование отдельным агентам возможно по выбору пользователя; оно не требуется для корректного выполнения плана.
План подготовлен для проверки перед началом реализации. Во время исполнения чекбоксы и результаты обновляются по фактическим проверкам, а не по наличию файлов.
+50
View File
@@ -0,0 +1,50 @@
# Проверка FastAPI-версии — 2026-10-01
Рабочий проект: `P:\Coding\Python\NetologyWebParserSite`.
## Архитектура
Rio полностью удалён из кода приложения, pyproject.toml и uv.lock. Сервер — FastAPI; страницы — Jinja2, HTML и CSS; интерактивность — небольшие JavaScript-модули с HTTP-запросами. WebSocket не используется.
Кабинет поддерживает импорт CSV/JSON/JSONL, выбор вложенного массива JSON, настройку четырёх представлений, живой предпросмотр, сохранение черновика, публикацию и снятие версии. Обычные учебные страницы используют ссылочную пагинацию. Динамические страницы отдают первую порцию данных в HTML и поддерживают дозагрузку через JavaScript.
Хранилище SQLite, формат публикаций, учётные данные и адреса опубликованных страниц сохранены. Тесты работают с изолированными данными; рабочий `.env` не изменялся. Отдельно от тестов исправлен найденный черновик с точным содержимым примера questionnaires.json: две записи участников и автоматически настроенные анкеты. Прежний черновик сохранён в `data/backups/b2cce4ed61034aaab20242c364c21924-revision-3.json`; опубликованные версии и исходный файл не изменены.
## Результаты
- `uv sync --locked`: зависимости установлены, Rio отсутствует.
- Полный pytest с `LAB_BROWSER_TESTS=1`, `PLAYWRIGHT_CHANNEL=chrome`: **49 passed**, в том числе 14 реальных браузерных сценариев; 27,97 секунды.
- Один сторонний warning: Starlette предупреждает о будущем переходе TestClient с httpx на httpx2. Ошибок тестов нет.
- Импорт, предпросмотр, публикация, конфликт ревизий, замена исходника без изменения опубликованной версии, авторизация и CSRF проверены HTTP-тестами.
- Браузер проверяет импорт CSV и вложенного JSON, настройку заполненных анкет, блокировку сохранения после неуспешной проверки файла, изменение подписей в предпросмотре и предупреждение о несохранённых изменениях.
- Все четыре представления проверены парсерами и Chrome: 51 запись, дозагрузка, поиск, фильтры, отсутствие скрытых полей в публичных данных.
- Переход «Parser Lab / Коллекции» и выход из кабинета проверены.
- Единый favicon проверен на главной, входе, в кабинете и обоих типах учебных страниц.
- Одинаковые размеры, шрифты, цвета и положение элементов обычной/динамической страницы проверены на ширине 1440 и 390 пикселей.
- Снимки реального интерфейса кабинета и четырёх форматов проверены на настольном и мобильном экранах. Горизонтальная прокрутка широких таблиц остаётся внутри таблицы.
- `uv build`: собраны sdist и wheel с HTML/CSS/JS, шрифтами и favicon.
- Отдельное окружение `uv sync --locked --no-dev --no-editable`: установлен 21 пакет. Запуск установленного пакета, healthz, главная, вход, перенаправление из закрытого кабинета и статические ресурсы проверены без Rio и pytest.
Все проверки выполнялись на временных данных. Тестовые серверы остановлены.
## Запуск
Из корня проекта:
```powershell
uv sync --locked
uv run --locked --env-file .env python -m parser_lab
```
Если предыдущая версия ещё запущена, остановите её перед запуском новой. Повторно создавать `.env` и импортировать существующие данные не нужно.
Dockerfile и Compose сохранены. Сборка и запуск самого контейнера в этой среде **не проверены**, поскольку Docker недоступен. Проверена отдельная production-установка с теми же uv-зависимостями.
`design-spec.md` и `implementation-plan.md` помечены как архив первоначальной Rio-архитектуры. Актуальная инструкция — README.md.
## Исправление импорта questionnaires.json
Браузерный сценарий загружает реальный `examples/data/questionnaires.json`, нажимает «Проверить файл» и «Сохранить и настроить», не выбирая вручную JSON-пути. Проверяются две анкеты A01/A02 и шесть исходных ответов: 8, Прогулка, true, 5, null, false. Импорт и редактор показывают отформатированные анкеты. Отдельный сценарий исправляет старый импорт целого документа кнопкой «Настроить анкеты автоматически», проверяет сохранение после перезагрузки и публикацию.
HTTP-проверки подтверждают независимость распознавания от имени файла, отказ от автоматического выбора при нескольких подходящих массивах, сохранение опубликованного снимка и исходника при исправлении, а также защиту от устаревшей ревизии. При неизвестной структуре остаётся ручной выбор полей.
+16
View File
@@ -0,0 +1,16 @@
import json
from pathlib import Path
from bs4 import BeautifulSoup
def extract(markup):
soup=BeautifulSoup(markup,'html.parser')
records=[]
def field_data(field):
return {'pointer':field['data-field'],'label':field.get('data-label',''),'state':field['data-value-state'],'type':field.get('data-value-type','string'),'text':field.get_text()}
for record in soup.select('.record[data-record-id]'):
records.append({'id':record['data-record-id'],'fields':[field_data(f) for f in record.select('.field[data-field]:not([data-role])')],
'roles':{f['data-role']:field_data(f) for f in record.select('.field[data-role]')}})
return records
def save(records,path):
Path(path).write_text(''.join(json.dumps(r,ensure_ascii=False)+'\n' for r in records),encoding='utf-8')
+26
View File
@@ -0,0 +1,26 @@
"""python scrape_html.py URL --output records.jsonl"""
import argparse
from urllib.parse import urljoin,urlsplit
import requests
from bs4 import BeautifulSoup
from common import extract,save
def scrape(url):
records=[]; pages=set(); ids=set(); origin=urlsplit(url).netloc
with requests.Session() as session:
while url:
if url in pages: raise RuntimeError('Зацикленная пагинация')
pages.add(url)
response=session.get(url,timeout=30); response.raise_for_status()
batch=extract(response.text)
for record in batch:
if record['id'] in ids: raise RuntimeError('Повтор записи между страницами')
ids.add(record['id']); records.append(record)
next_link=BeautifulSoup(response.text,'html.parser').select_one('a[rel=next]')
url=urljoin(url,next_link['href']) if next_link else ''
if url and urlsplit(url).netloc!=origin: raise RuntimeError('Ссылка ведёт на другой сервер')
return records
if __name__=='__main__':
p=argparse.ArgumentParser();p.add_argument('url');p.add_argument('--output',default='records.jsonl');a=p.parse_args()
records=scrape(a.url);save(records,a.output);print(f'Сохранено записей: {len(records)}')
+27
View File
@@ -0,0 +1,27 @@
"""python scrape_interactive.py URL --output records.jsonl [--channel chrome]"""
import argparse
from playwright.sync_api import sync_playwright
from common import extract,save
def scrape(url,channel=None):
with sync_playwright() as p:
browser=p.chromium.launch(headless=True,**({'channel':channel} if channel else {}))
try:
page=browser.new_page()
response=page.goto(url)
if not response or not response.ok: raise RuntimeError('Публикация недоступна')
page.locator('.lab-records').wait_for(timeout=30000)
while True:
more=page.get_by_role('button',name='Загрузить ещё',exact=True)
if not more.is_visible(): break
old=page.locator('.record[data-record-id]').count()
more.click()
page.wait_for_function('(n)=>document.querySelectorAll(".record[data-record-id]").length > n',arg=old,timeout=10000)
records=extract(page.content())
if len({r['id'] for r in records})!=len(records): raise RuntimeError('Обнаружены дубликаты записей')
return records
finally: browser.close()
if __name__=='__main__':
p=argparse.ArgumentParser();p.add_argument('url');p.add_argument('--output',default='records.jsonl');p.add_argument('--channel');a=p.parse_args()
records=scrape(a.url,a.channel);save(records,a.output);print(f'Сохранено записей: {len(records)}')
+31
View File
@@ -0,0 +1,31 @@
[build-system]
requires = ["setuptools>=75"]
build-backend = "setuptools.build_meta"
[project]
name = "parser-lab"
version = "1.0.0"
requires-python = ">=3.12,<3.13"
dependencies = ["fastapi==0.141.1", "python-multipart>=0.0.20", "jinja2==3.1.6", "argon2-cffi==25.1.0", "uvicorn==0.52.4"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
parser_lab = ["web/templates/*.html", "web/static/*"]
[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]
[dependency-groups]
dev = [
"pytest==9.1.1",
"httpx==0.28.1",
"requests==2.34.2",
"beautifulsoup4==4.15.0",
"playwright==1.63.0",
]
[tool.uv]
required-version = ">=0.8.14"
+1
View File
@@ -0,0 +1 @@
"""Parser Lab — учебные данные в разных представлениях."""
+4
View File
@@ -0,0 +1,4 @@
import uvicorn
from .app import create_app
if __name__=='__main__':
uvicorn.run(create_app(),host='0.0.0.0',port=8080,proxy_headers=False,access_log=False)
+171
View File
@@ -0,0 +1,171 @@
"""Teacher HTTP routes. Data records and published snapshots never come from client config."""
import json
from fastapi import Request
from starlette.responses import HTMLResponse
from .web import render,records_fragment
from .importing import read_upload,ImportOptions,parse_json
from .paths import field_summary,field_paths,resolve_pointer,path_label
from .views import default_view,validate_view,project_record,KINDS
from .repository import Conflict
from .questionnaires import object_arrays,questionnaire_view,questionnaire_structure
def config_from_payload(draft,payload):
if not isinstance(payload,dict) or not isinstance(payload.get('revision'),int):raise ValueError('Некорректная версия черновика')
if payload['revision']!=draft['revision']:raise Conflict('Набор изменён в другой вкладке. Откройте его заново.')
for key in ('title','description','slug'):
if key in payload:
if not isinstance(payload[key],str) or len(payload[key])>10000:raise ValueError('Некорректное текстовое поле')
draft[key]=payload[key]
views=payload.get('views',{})
if not isinstance(views,dict) or any(k not in KINDS for k in views):raise ValueError('Неизвестное представление')
cleaned={}
for kind,source in views.items():
if not isinstance(source,dict):raise ValueError('Некорректные настройки представления')
view=default_view(kind,[])
for key,value in source.items():
if key not in view or key=='kind':continue
if key=='columns':
if not isinstance(value,list) or len(value)>1000:raise ValueError('Некорректный список полей')
columns=[]
for col in value:
if not isinstance(col,dict) or not all(isinstance(col.get(k),str) for k in ('pointer','label')):raise ValueError('Некорректное поле')
if len(col['pointer'])>2000 or len(col['label'])>1000:raise ValueError('Слишком длинное поле')
columns.append({'pointer':col['pointer'],'label':col['label']})
view[key]=columns
elif key=='page_size':
if value not in (10,25,50,100):raise ValueError('Неверный размер страницы')
view[key]=value
else:
if not isinstance(value,str) or len(value)>2000:raise ValueError('Некорректные настройки поля')
view[key]=value
cleaned[kind]=view
draft['views']=cleaned
return draft
def install_admin(app,auth,repo,settings):
def page(request,screen,**context):
token=auth.from_headers(request.headers)
return HTMLResponse(render('admin.html',screen=screen,csrf=auth.csrf(token),username=settings.username,
kinds=KINDS,**context),headers={'Cache-Control':'no-store'})
@app.get('/admin')
async def catalog(request:Request):
auth.from_headers(request.headers)
datasets=repo.list()
return page(request,'catalog',datasets=datasets,active={d['id']:any(v['active'] for v in repo.versions(d['slug'])) for d in datasets})
@app.get('/admin/import')
async def import_page(request:Request):
auth.from_headers(request.headers)
ident=request.query_params.get('replace','')
return page(request,'import',replace=repo.get(ident) if ident else None,max_bytes=settings.max_bytes)
@app.get('/admin/datasets/{ident}')
async def editor(request:Request,ident:str):
auth.from_headers(request.headers);d=repo.get(ident)
data={k:d[k] for k in ('id','title','slug','description','revision','views')}
data['fields']=[{'pointer':p,'label':path_label(p),'missing':info['missing']} for p,info in field_summary(d['records']).items()]
data['defaults']={k:default_view(k,field_paths(d['records'])[:6]) for k in KINDS}
detected=questionnaire_structure(d['records'])
if detected and not detected['pointer']:data['defaults']['questionnaire']=detected['view']
repair=detected if detected and (detected['pointer'] or 'questionnaire' not in d['views'] or validate_view(d['views']['questionnaire'],d['records'])) else None
return page(request,'edit',draft=d,editor_data=data,questionnaire_repair=repair)
@app.get('/admin/datasets/{ident}/versions')
async def versions(request:Request,ident:str):
auth.from_headers(request.headers);d=repo.get(ident)
versions=repo.versions(d['slug'])
for v in versions:v['kinds']=list(repo.publication(d['slug'],v['version'])['views']) if v['active'] else []
return page(request,'versions',draft=d,versions=versions)
async def uploaded(request):
form=await request.form(max_files=1,max_fields=12,max_part_size=settings.max_bytes+1)
file=form.get('file')
if file is None or not hasattr(file,'read'):raise ValueError('Выберите файл')
try:raw=await file.read(settings.max_bytes+1)
finally:await file.close()
filename=file.filename or ''
options=ImportOptions(str(form.get('encoding','utf-8-sig')),str(form.get('delimiter','')),str(form.get('pointer','')))
result=read_upload(raw,filename,options,max_bytes=settings.max_bytes,max_records=settings.max_records)
pointer=options.record_pointer
detected=questionnaire_structure(result.records)
if not pointer and form.get('root_confirmed')!='1' and filename.lower().endswith('.json') and detected and detected['pointer']:
pointer=detected['pointer']
result=read_upload(raw,filename,ImportOptions(record_pointer=pointer),max_bytes=settings.max_bytes,max_records=settings.max_records)
result.warnings.append(f'Распознаны анкеты: {len(result.records)} участников. Записи выбраны из {pointer}.')
return form,raw,filename,result,pointer
@app.post('/api/admin/inspect')
async def inspect_file(request:Request):
form,raw,filename,result,pointer=await uploaded(request)
arrays=[];choose_record_level=False
if filename.lower().endswith('.json'):
document=parse_json(raw.decode('utf-8-sig'))
arrays=object_arrays(document) if isinstance(document,dict) else ['']
choose_record_level=isinstance(document,dict) and bool(arrays) and not pointer and form.get('root_confirmed')!='1'
view=questionnaire_view(result.records) or default_view('table',result.fields[:8])
return {'count':len(result.records),'fields':result.fields,'warnings':result.warnings,'arrays':arrays,'choose_record_level':choose_record_level,'record_pointer':pointer,
'html':records_fragment([project_record(r,view,str(i+1)) for i,r in enumerate(result.records[:20])],view['kind'])}
@app.post('/api/admin/import')
async def import_file(request:Request):
form,raw,filename,result,pointer=await uploaded(request)
if filename.lower().endswith('.json') and not pointer and form.get('root_confirmed')!='1':
document=parse_json(raw.decode('utf-8-sig'))
if isinstance(document,dict) and object_arrays(document):
raise ValueError('В документе несколько возможных уровней данных. Выберите «Записи внутри JSON» и проверьте файл повторно.')
if form.get('replace_id'):
d=repo.replace_source(str(form['replace_id']),int(form.get('revision','0')),raw,filename,result.records)
else:d=repo.create(str(form.get('title','')),raw,filename,result.records)
view=questionnaire_view(result.records)
if view:
d['views']={'questionnaire':view};d=repo.save(d,d['revision'])
return {'id':d['id'],'slug':d['slug'],'revision':d['revision']}
@app.post('/api/admin/datasets/{ident}/configure-questionnaire')
async def configure_questionnaire(request:Request,ident:str):
payload=await request.json();d=repo.get(ident)
if not isinstance(payload,dict) or type(payload.get('revision')) is not int:raise ValueError('Некорректная версия черновика')
if payload['revision']!=d['revision']:raise Conflict('Коллекция изменена. Обновите страницу.')
detected=questionnaire_structure(d['records'])
if not detected:raise ValueError('Не удалось однозначно определить структуру анкет. Выберите записи при импорте.')
if len(detected['records'])>settings.max_records:raise ValueError('Превышен лимит записей')
if detected['pointer']:
d['records']=detected['records'];d['views']={}
d['views']['questionnaire']=detected['view']
d=repo.save(d,d['revision'])
return {'revision':d['revision'],'count':len(d['records'])}
@app.put('/api/admin/datasets/{ident}')
async def save(request:Request,ident:str):
d=config_from_payload(repo.get(ident),await request.json())
return {'revision':repo.save(d,d['revision'])['revision']}
@app.post('/api/admin/datasets/{ident}/preview')
async def live_preview(request:Request,ident:str):
d=config_from_payload(repo.get(ident),await request.json());kind=request.query_params.get('kind','table')
if kind not in d['views']:return {'html':'','errors':['Включите представление, чтобы увидеть результат.']}
view=d['views'][kind];errors=validate_view(view,d['records'])
return {'errors':errors,'html':'' if errors else records_fragment([project_record(r,view,str(i+1)) for i,r in enumerate(d['records'][:5])],kind)}
@app.get('/api/admin/datasets/{ident}/fields')
async def nested_fields(request:Request,ident:str):
auth.from_headers(request.headers);d=repo.get(ident);rows=[]
for r in d['records']:
_,value=resolve_pointer(r,request.query_params.get('pointer',''))
if isinstance(value,list):rows.extend(x for x in value if isinstance(x,dict))
return {'fields':field_paths(rows),'scalar_fields':[p for p in field_paths(rows) if not any(isinstance(resolve_pointer(row,p)[1],(dict,list)) for row in rows)]}
@app.post('/api/admin/datasets/{ident}/publish')
async def publish(request:Request,ident:str):
payload=await request.json()
if not isinstance(payload,dict) or type(payload.get('revision')) is not int:raise ValueError('Некорректная версия черновика')
p=repo.publish(ident,payload['revision'])
return {'version':p['version']}
@app.post('/api/admin/datasets/{ident}/unpublish/{version}')
async def unpublish(request:Request,ident:str,version:int):
d=repo.get(ident);repo.unpublish(d['slug'],version)
return {'ok':True}
+156
View File
@@ -0,0 +1,156 @@
"""FastAPI application: server-rendered pages and small progressive-enhancement APIs."""
import secrets
from urllib.parse import parse_qs
from fastapi import FastAPI, Request
from starlette.responses import HTMLResponse, RedirectResponse, JSONResponse, Response
from starlette.staticfiles import StaticFiles
from .config import Settings
from .repository import Repository, NotFound, Conflict
from .auth import Auth, Unauthorized, RateLimited
from .web import render, public_page, ROOT
class Boundary:
def __init__(self,app,auth): self.app=app; self.auth=auth
async def __call__(self,scope,receive,send):
if scope['type']!='http': return await self.app(scope,receive,send)
headers={k.decode().lower():v.decode() for k,v in scope.get('headers',[])}
changing=scope.get('method') in ('POST','PUT','PATCH','DELETE')
if changing:
if headers.get('origin','').rstrip('/') not in self.auth.settings.origins:
return await JSONResponse({'error':'Недопустимый источник запроса'},403)(scope,receive,send)
if scope['path'].startswith('/api/admin/'):
try: token=self.auth.from_headers(headers)
except Unauthorized:
return await JSONResponse({'error':'Войдите в кабинет'},401)(scope,receive,send)
if not self.auth.valid_csrf(token,headers.get('x-csrf-token','')):
return await JSONResponse({'error':'Обновите страницу кабинета'},403)(scope,receive,send)
limit=self.auth.settings.max_bytes+65536 if scope['path'] in ('/api/admin/import','/api/admin/inspect') else 1024*1024
try: declared=int(headers.get('content-length','0'))
except ValueError: return await Response(status_code=400)(scope,receive,send)
if declared>limit: return await JSONResponse({'error':'Файл или запрос слишком большой'},413)(scope,receive,send)
body=bytearray()
while True:
event=await receive()
if event['type']=='http.disconnect': return
body.extend(event.get('body',b''))
if len(body)>limit: return await JSONResponse({'error':'Файл или запрос слишком большой'},413)(scope,receive,send)
if not event.get('more_body',False): break
async def replay(): return {'type':'http.request','body':bytes(body),'more_body':False}
return await self.app(scope,replay,send)
await self.app(scope,receive,send)
def create_app(settings=None):
settings=settings or Settings.from_env()
repo=Repository(settings.data_dir);auth=Auth(settings,repo)
app=FastAPI(title='Parser Lab',docs_url=None,redoc_url=None,openapi_url=None)
app.state.repo=repo;app.state.auth=auth
app.add_middleware(Boundary,auth=auth)
app.mount('/static',StaticFiles(directory=ROOT/'static'),name='static')
@app.exception_handler(ValueError)
async def value_error(request,error):
status=404 if isinstance(error,NotFound) else 409 if isinstance(error,Conflict) else 400
if request.url.path.startswith('/api/') or request.query_params.get('fragment'):
return JSONResponse({'error':str(error)},status)
return HTMLResponse(render('error.html',message=str(error)),status)
@app.exception_handler(Unauthorized)
async def unauthorized(request,error):
if request.url.path.startswith('/api/'): return JSONResponse({'error':str(error)},401)
return RedirectResponse('/login',303)
@app.get('/healthz')
async def health():
with repo.connect() as db: db.execute('SELECT 1').fetchone()
return {'status':'ok'}
@app.get('/')
async def home(): return HTMLResponse(render('home.html'))
@app.get('/login')
async def login_page():
nonce=secrets.token_urlsafe(32)
response=HTMLResponse(render('login.html',csrf=auth.csrf(nonce)))
response.set_cookie('lab_login',nonce,httponly=True,secure=settings.secure_cookie,samesite='strict',max_age=600)
response.headers['Cache-Control']='no-store'
return response
@app.post('/login')
async def login(request:Request):
raw=await request.body()
if len(raw)>8192:return Response(status_code=413)
form={k:v[0] for k,v in parse_qs(raw.decode()).items()}
nonce=request.cookies.get('lab_login','')
if not auth.valid_csrf(nonce,form.get('csrf','')):return Response('Обновите страницу входа',status_code=403)
try:token=auth.login(form.get('username',''),form.get('password',''),request.client.host)
except (Unauthorized,RateLimited) as e:
return HTMLResponse(render('login.html',csrf=auth.csrf(nonce),error=str(e)),429 if isinstance(e,RateLimited) else 401)
response=RedirectResponse('/admin',303)
response.set_cookie('lab_session',token,httponly=True,secure=settings.secure_cookie,samesite='strict',max_age=43200)
response.delete_cookie('lab_login')
return response
@app.post('/logout')
async def logout(request:Request):
token=auth.from_headers(request.headers)
form=parse_qs((await request.body()).decode())
if not auth.valid_csrf(token,form.get('csrf',[''])[0]):return Response(status_code=403)
auth.logout(token)
response=RedirectResponse('/login',303);response.delete_cookie('lab_session')
return response
async def serve(request,slug,version,kind,record_id=None,mode='html'):
p=repo.publication(slug,version)
return public_response(request,p,kind,f'/{mode}/{slug}/v/{version}/{kind}',record_id,False,mode=='interactive')
def public_response(request,p,kind,base,record_id=None,preview=False,live=False):
page=int(request.query_params.get('page','1'))
search=request.query_params.get('q',''); category=request.query_params.get('category') or None
if request.query_params.get('fragment')=='1' and live:
from .query import select_records
from .web import records_fragment
view=p['views'].get(kind)
if view is None:raise NotFound('Представление не найдено')
result=select_records(p['records'],view,page,search,category)
return JSONResponse({'html':records_fragment(result['items'],kind,base),'total':result['total'],
'has_next':result['has_next'],'page':page},headers={'Cache-Control':'no-store'})
return HTMLResponse(public_page(p,kind,base,page,search,category,record_id,preview,live),headers={'Cache-Control':'no-store'})
@app.get('/html/{slug}/v/{version}/{kind}')
async def listing(request:Request,slug:str,version:int,kind:str):return await serve(request,slug,version,kind)
@app.get('/html/{slug}/v/{version}/{kind}/records/{record_id}')
async def detail(request:Request,slug:str,version:int,kind:str,record_id:str):return await serve(request,slug,version,kind,record_id)
@app.get('/interactive/{slug}/v/{version}/{kind}')
async def interactive(request:Request,slug:str,version:int,kind:str):return await serve(request,slug,version,kind,mode='interactive')
@app.get('/interactive/{slug}/v/{version}/{kind}/records/{record_id}')
async def interactive_detail(request:Request,slug:str,version:int,kind:str,record_id:str):return await serve(request,slug,version,kind,record_id,'interactive')
@app.get('/preview/{ident}/{kind}')
async def preview(request:Request,ident:str,kind:str):
auth.from_headers(request.headers)
return public_response(request,repo.get(ident),kind,f'/preview/{ident}/{kind}',preview=True)
@app.get('/preview/{ident}/{kind}/records/{record_id}')
async def preview_detail(request:Request,ident:str,kind:str,record_id:str):
auth.from_headers(request.headers)
return public_response(request,repo.get(ident),kind,f'/preview/{ident}/{kind}',record_id,True)
@app.get('/dynamic-preview/{ident}/{kind}')
async def dynamic_preview(request:Request,ident:str,kind:str):
auth.from_headers(request.headers)
return public_response(request,repo.get(ident),kind,f'/dynamic-preview/{ident}/{kind}',preview=True,live=True)
@app.get('/dynamic-preview/{ident}/{kind}/records/{record_id}')
async def dynamic_detail(request:Request,ident:str,kind:str,record_id:str):
auth.from_headers(request.headers)
return public_response(request,repo.get(ident),kind,f'/dynamic-preview/{ident}/{kind}',record_id,True,True)
from .admin import install_admin
install_admin(app,auth,repo,settings)
return app
+60
View File
@@ -0,0 +1,60 @@
import hashlib
import json
import hmac
import secrets
import time
from http.cookies import SimpleCookie
from collections import defaultdict
from argon2 import PasswordHasher
from argon2.exceptions import VerificationError, InvalidHashError
class Unauthorized(ValueError): pass
class RateLimited(ValueError): pass
class Auth:
def __init__(self,settings,repo):
self.settings=settings; self.repo=repo
self.attempts=defaultdict(list)
self.password_hash=PasswordHasher().hash(settings.password)
credentials=json.dumps([settings.username,settings.password],ensure_ascii=False).encode()
self.fingerprint=hmac.new(settings.secret.encode(),credentials,hashlib.sha256).hexdigest()
def login(self,username,password,client):
now=time.time()
tries=[t for t in self.attempts[client] if now-t<300]
self.attempts[client]=tries
if len(tries)>=5: raise RateLimited('Слишком много попыток. Повторите через 5 минут.')
try: good=PasswordHasher().verify(self.password_hash,password)
except (VerificationError,InvalidHashError): good=False
if not good or not hmac.compare_digest(username.encode(),self.settings.username.encode()):
self.attempts[client].append(now)
raise Unauthorized('Неверный логин или пароль')
self.attempts.pop(client,None)
token=secrets.token_urlsafe(32)
with self.repo.connect() as db:
db.execute('DELETE FROM sessions WHERE expires<? OR fingerprint<>?',(now,self.fingerprint))
db.execute('INSERT INTO sessions VALUES (?,?,?)',(self.digest(token),now+43200,self.fingerprint))
return token
@staticmethod
def digest(token): return hashlib.sha256(token.encode()).hexdigest()
def check(self,token):
with self.repo.connect() as db:
row=db.execute('SELECT 1 FROM sessions WHERE token=? AND expires>? AND fingerprint=?',(self.digest(token),time.time(),self.fingerprint)).fetchone()
if not row: raise Unauthorized('Сессия завершена. Войдите снова.')
return token
def from_headers(self,headers):
cookies=SimpleCookie()
try: cookies.load(headers.get('cookie',''))
except Exception: raise Unauthorized('Войдите в кабинет')
token=cookies['lab_session'].value if 'lab_session' in cookies else ''
return self.check(token)
def logout(self,token):
with self.repo.connect() as db: db.execute('DELETE FROM sessions WHERE token=?',(self.digest(token),))
def csrf(self,token): return hmac.new(self.settings.secret.encode(),token.encode(),hashlib.sha256).hexdigest()
def valid_csrf(self,token,value): return bool(token and value) and hmac.compare_digest(self.csrf(token),value)
+24
View File
@@ -0,0 +1,24 @@
import argparse
import getpass
import secrets
from pathlib import Path
def quote_env(value):
# Double-quoted escapes are supported by both uv's dotenvy and Compose.
escaped = value.replace("\\", "\\\\").replace('"', '\\"').replace('$', '\\$')
return '"' + escaped + '"'
def main():
parser=argparse.ArgumentParser()
parser.add_argument('command',choices=['init-env'])
parser.add_argument('--path',default='.env')
args=parser.parse_args()
path=Path(args.path)
if path.exists(): raise SystemExit('Файл уже существует; перезапись запрещена.')
user=input('Логин преподавателя [teacher]: ').strip() or 'teacher'
password=getpass.getpass('Пароль (не менее 10 символов): ')
if len(password)<10 or password!=getpass.getpass('Повторите пароль: '): raise SystemExit('Пароли не совпадают или слишком короткие')
path.write_text(f"LAB_ADMIN_USER={quote_env(user)}\nLAB_ADMIN_PASSWORD={quote_env(password)}\nLAB_SECRET={secrets.token_urlsafe(48)}\nLAB_ORIGINS=http://localhost:8080\nLAB_SECURE_COOKIE=false\nLAB_PORT=8080\n",encoding='utf-8')
print(f'Создан {path}. Перед публикацией настройте домен и HTTPS по README.')
if __name__=='__main__': main()
+26
View File
@@ -0,0 +1,26 @@
import os
from pathlib import Path
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Settings:
data_dir: Path
username: str
password: str = field(repr=False)
secret: str = field(repr=False)
secure_cookie: bool = True
origins: tuple[str,...] = ()
max_bytes: int = 25*1024*1024
max_records: int = 50000
@classmethod
def from_env(cls):
username=os.environ.get('LAB_ADMIN_USER','')
password=os.environ.get('LAB_ADMIN_PASSWORD','')
secret=os.environ.get('LAB_SECRET','')
if not username or len(password)<10 or len(secret)<32:
raise ValueError('Задайте LAB_ADMIN_USER, LAB_ADMIN_PASSWORD (не менее 10 символов) и LAB_SECRET (не менее 32 символов). См. README.')
origins=tuple(x.strip().rstrip('/') for x in os.environ.get('LAB_ORIGINS','http://localhost:8080').split(',') if x.strip())
return cls(Path(os.environ.get('LAB_DATA_DIR','./data')),username,password,secret,
os.environ.get('LAB_SECURE_COOKIE','true').lower()=='true',origins,
int(os.environ.get('LAB_MAX_BYTES',25*1024*1024)),int(os.environ.get('LAB_MAX_RECORDS',50000)))
+97
View File
@@ -0,0 +1,97 @@
import csv
import io
import json
from dataclasses import dataclass, field
from pathlib import Path
from .paths import resolve_pointer, field_paths
class ImportErrorDetail(ValueError):
pass
@dataclass
class ImportOptions:
encoding: str = 'utf-8-sig'
delimiter: str = ''
record_pointer: str = ''
@dataclass
class ImportResult:
records: list[dict]
fields: list[str]
warnings: list[str] = field(default_factory=list)
def _pairs(pairs):
result = {}
for key, value in pairs:
if key in result:
raise ImportErrorDetail(f'Повторяющийся ключ: {key}')
result[key] = value
return result
def _bad_constant(value):
raise ImportErrorDetail(f'Недопустимое число: {value}')
def parse_json(text):
return json.loads(text, object_pairs_hook=_pairs, parse_constant=_bad_constant)
def _depth(value, level=0):
if level > 20:
raise ImportErrorDetail('Глубина JSON превышает 20 уровней')
if isinstance(value, dict):
for v in value.values(): _depth(v, level+1)
elif isinstance(value, list):
for v in value: _depth(v, level+1)
def read_upload(data, filename, options, *, max_bytes=25*1024*1024, max_records=50000):
if not data or len(data)>max_bytes:
raise ImportErrorDetail(f'Файл пуст или превышает лимит {max_bytes//1024//1024} МиБ')
ext = Path(filename).suffix.lower()
if ext not in ('.csv','.json','.jsonl'):
raise ImportErrorDetail('Выберите CSV, JSON или JSONL')
warnings = []
try:
text = data.decode(options.encoding if ext=='.csv' else 'utf-8-sig')
if ext == '.csv':
delimiter = options.delimiter
if not delimiter:
try: delimiter = csv.Sniffer().sniff(text[:8192], delimiters=',;\t').delimiter
except csv.Error: delimiter = ','
warnings.append(f'Определён разделитель: {repr(delimiter)}')
if delimiter not in (',',';','\t'):
raise ImportErrorDetail('Неподдерживаемый разделитель')
reader = csv.reader(io.StringIO(text, newline=''), delimiter=delimiter, strict=True)
header = next(reader, [])
if not header or any(not h.strip() for h in header) or len(set(header))!=len(header):
raise ImportErrorDetail('Заголовки должны быть непустыми и уникальными')
records = []
for row in reader:
if len(row)!=len(header):
raise ImportErrorDetail(f'Строка {reader.line_num}: число ячеек не совпадает с заголовками')
records.append(dict(zip(header,row)))
if len(records)>max_records: raise ImportErrorDetail('Превышен лимит записей')
elif ext == '.jsonl':
records = []; blank = 0
for number, line in enumerate(text.splitlines(),1):
if not line.strip(): blank+=1; continue
try: record = parse_json(line)
except (ValueError, RecursionError) as e: raise ImportErrorDetail(f'Строка {number}: {e}') from e
if not isinstance(record,dict): raise ImportErrorDetail(f'Строка {number}: ожидается объект')
records.append(record)
if len(records)>max_records: raise ImportErrorDetail('Превышен лимит записей')
if blank: warnings.append(f'Пропущено пустых строк: {blank}')
else:
value = parse_json(text)
_depth(value)
if options.record_pointer:
found,value = resolve_pointer(value,options.record_pointer)
if not found: raise ImportErrorDetail('Выбранный путь отсутствует в JSON')
records = [value] if isinstance(value,dict) else value
if not isinstance(records,list) or not records or not all(isinstance(x,dict) for x in records):
raise ImportErrorDetail('Ожидается непустой массив объектов или один объект')
if len(records)>max_records: raise ImportErrorDetail('Превышен лимит записей')
for record in records: _depth(record)
return ImportResult(records,field_paths(records),warnings)
except ImportErrorDetail:
raise
except (UnicodeError, ValueError, csv.Error, RecursionError) as e:
raise ImportErrorDetail(f'Не удалось прочитать файл: {e}') from e
+38
View File
@@ -0,0 +1,38 @@
def resolve_pointer(record, pointer):
if pointer == '':
return True, record
if not pointer.startswith('/'):
return False, None
value = record
for part in pointer[1:].split('/'):
part = part.replace('~1', '/').replace('~0', '~')
if isinstance(value, dict) and part in value:
value = value[part]
elif isinstance(value, list) and part.isdigit() and int(part) < len(value):
value = value[int(part)]
else:
return False, None
return True, value
def field_paths(records):
paths = {}
def walk(value, prefix):
if isinstance(value, dict):
for key, child in value.items():
p = prefix + '/' + key.replace('~','~0').replace('/','~1')
paths[p] = None
walk(child, p)
for record in records:
walk(record, '')
return list(paths)
def field_summary(records):
result={}
for p in field_paths(records):
result[p]={'label':p.rsplit('/',1)[-1].replace('~1','/').replace('~0','~'),
'parent':p.rsplit('/',1)[0],
'missing':sum(not resolve_pointer(r,p)[0] for r in records)}
return result
def path_label(pointer):
return ' › '.join(x.replace('~1','/').replace('~0','~') for x in pointer.split('/')[1:])
+30
View File
@@ -0,0 +1,30 @@
import json
from .paths import resolve_pointer
from .views import project_record
from .repository import NotFound
def select_records(records,view,page=1,search='',category=None):
if not isinstance(page,int) or page<1: raise ValueError('Некорректный номер страницы')
selected=[]
for n,record in enumerate(records,1):
if search and view.get('search_pointer'):
found,value=resolve_pointer(record,view['search_pointer'])
if not found or not isinstance(value,str) or search.casefold() not in value.casefold(): continue
if category is not None and view.get('category_pointer'):
found,value=resolve_pointer(record,view['category_pointer'])
if not found or json.dumps(value,ensure_ascii=False)!=category: continue
selected.append((n,record))
size=view['page_size']; start=(page-1)*size
if page>1 and start>=len(selected): raise NotFound('Страница не найдена')
return {'items':[project_record(r,view,n) for n,r in selected[start:start+size]],'total':len(selected),'page':page,'page_size':size,'has_next':start+size<len(selected)}
def categories(records,view):
p=view.get('category_pointer')
if not p: return []
seen={}
for r in records:
found,v=resolve_pointer(r,p)
if found and not isinstance(v,(dict,list)):
key=json.dumps(v,ensure_ascii=False)
seen[key]=v if isinstance(v,str) else key
return list(seen.items())
+38
View File
@@ -0,0 +1,38 @@
"""Conservative recognition of questionnaire records, independent of filenames."""
from .paths import field_paths, resolve_pointer
from .views import default_view, validate_view
def object_arrays(document):
return [p for p in field_paths([document])
if isinstance(resolve_pointer(document,p)[1],list)
and resolve_pointer(document,p)[1]
and all(isinstance(row,dict) for row in resolve_pointer(document,p)[1])]
def questionnaire_view(records):
candidates=[]
for pointer in field_paths(records):
arrays=[resolve_pointer(row,pointer)[1] for row in records]
if not all(value is None or isinstance(value,list) for value in arrays):continue
answers=[item for value in arrays if isinstance(value,list) for item in value]
if not answers or not all(isinstance(item,dict) and isinstance(item.get('question'),str) and 'answer' in item for item in answers):continue
candidates.append(pointer)
if len(candidates)!=1:return None
scalars=[p for p in field_paths(records) if all(not isinstance(resolve_pointer(row,p)[1],(dict,list)) for row in records)]
title=next((p for p in ('/participant','/name','/id') if p in scalars),scalars[0] if scalars else '')
view=default_view('questionnaire',scalars[:6] or candidates)
view.update(questionnaire_mode='pairs',questions_pointer=candidates[0],question_pointer='/question',answer_pointer='/answer',title_pointer=title,text_pointer='')
return view if not validate_view(view,records) else None
def questionnaire_structure(records):
view=questionnaire_view(records)
if view:return {'pointer':'','records':records,'view':view}
if len(records)!=1:return None
candidates=[]
for pointer in object_arrays(records[0]):
rows=resolve_pointer(records[0],pointer)[1]
view=questionnaire_view(rows)
if view:candidates.append({'pointer':pointer,'records':rows,'view':view})
return candidates[0] if len(candidates)==1 else None
+114
View File
@@ -0,0 +1,114 @@
import copy
import json
import re
import sqlite3
import uuid
from pathlib import Path
from datetime import datetime, timezone
from contextlib import contextmanager
from .views import validate_view
class Conflict(ValueError): pass
class NotFound(ValueError): pass
def now(): return datetime.now(timezone.utc).isoformat(timespec='seconds')
def encode(value): return json.dumps(value,ensure_ascii=False,allow_nan=False)
class Repository:
def __init__(self, root):
self.root=Path(root); self.root.mkdir(parents=True,exist_ok=True)
(self.root/'sources').mkdir(exist_ok=True)
self.path=self.root/'lab.sqlite3'
with self.connect() as db:
db.executescript('''
CREATE TABLE IF NOT EXISTS schema_version(version INTEGER NOT NULL);
INSERT INTO schema_version SELECT 1 WHERE NOT EXISTS(SELECT 1 FROM schema_version);
CREATE TABLE IF NOT EXISTS datasets(id TEXT PRIMARY KEY,slug TEXT UNIQUE NOT NULL,revision INTEGER NOT NULL,body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS publications(slug TEXT NOT NULL,version INTEGER NOT NULL,active INTEGER NOT NULL,body TEXT NOT NULL,PRIMARY KEY(slug,version));
CREATE TABLE IF NOT EXISTS sessions(token TEXT PRIMARY KEY,expires REAL NOT NULL,fingerprint TEXT NOT NULL);
''')
@contextmanager
def connect(self):
db=sqlite3.connect(self.path,timeout=15)
db.row_factory=sqlite3.Row
try:
with db: yield db
finally: db.close()
def create(self,title,source,filename,records):
ident=uuid.uuid4().hex
source_path=self.root/'sources'/ident
temp=source_path.with_suffix('.tmp')
temp.write_bytes(source); temp.replace(source_path)
draft={'id':ident,'slug':'study-'+ident[:8],'revision':1,'title':title.strip() or 'Новый набор','description':'',
'records':records,'views':{},'source':ident,'filename':Path(filename).name,'updated':now()}
try:
with self.connect() as db: db.execute('INSERT INTO datasets VALUES (?,?,?,?)',(ident,draft['slug'],1,encode(draft)))
except Exception:
source_path.unlink(missing_ok=True); raise
return draft
def get(self,ident):
with self.connect() as db: row=db.execute('SELECT body FROM datasets WHERE id=?',(ident,)).fetchone()
if not row: raise NotFound('Набор не найден')
return json.loads(row[0])
def list(self):
with self.connect() as db: rows=db.execute('SELECT body FROM datasets ORDER BY rowid DESC').fetchall()
return [json.loads(r[0]) for r in rows]
def save(self,draft,expected_revision):
d=copy.deepcopy(draft)
if not re.fullmatch('[a-z0-9]+(?:-[a-z0-9]+)*',d['slug']): raise ValueError('Адрес: латинские строчные буквы, цифры и дефисы')
if not d['title'].strip(): raise ValueError('Введите название')
d.update(revision=expected_revision+1,updated=now())
with self.connect() as db:
db.execute('BEGIN IMMEDIATE')
old=db.execute('SELECT slug FROM datasets WHERE id=?',(d['id'],)).fetchone()
if not old: raise NotFound('Набор не найден')
if old[0]!=d['slug'] and db.execute('SELECT 1 FROM publications WHERE slug=?',(old[0],)).fetchone():
raise ValueError('После публикации адрес изменить нельзя')
try:
cur=db.execute('UPDATE datasets SET slug=?,revision=?,body=? WHERE id=? AND revision=?',(d['slug'],d['revision'],encode(d),d['id'],expected_revision))
except sqlite3.IntegrityError as e: raise ValueError('Этот адрес уже занят') from e
if cur.rowcount!=1: raise Conflict('Набор изменён в другой вкладке. Откройте его заново.')
return d
def replace_source(self,ident,revision,source,filename,records):
d=self.get(ident)
if d['revision']!=revision: raise Conflict('Набор изменён в другой вкладке')
name=uuid.uuid4().hex
path=self.root/'sources'/name
path.write_bytes(source)
d.update(source=name,filename=Path(filename).name,records=records,views={})
try: return self.save(d,revision)
except Exception: path.unlink(missing_ok=True); raise
def publish(self,ident,revision):
with self.connect() as db:
db.execute('BEGIN IMMEDIATE')
row=db.execute('SELECT body FROM datasets WHERE id=?',(ident,)).fetchone()
if not row: raise NotFound('Набор не найден')
d=json.loads(row[0])
if d['revision']!=revision: raise Conflict('Черновик изменился. Обновите страницу.')
if not d['records'] or not d['views']: raise ValueError('Нужны записи и хотя бы одно представление')
errors=[e for v in d['views'].values() for e in validate_view(v,d['records'])]
if errors: raise ValueError('; '.join(errors))
version=db.execute('SELECT COALESCE(MAX(version),0)+1 FROM publications WHERE slug=?',(d['slug'],)).fetchone()[0]
p={k:d[k] for k in ('slug','title','description','records','views')}
p.update(version=version,published=now())
db.execute('INSERT INTO publications VALUES (?,?,1,?)',(d['slug'],version,encode(p)))
return p
def publication(self,slug,version):
with self.connect() as db: row=db.execute('SELECT body FROM publications WHERE slug=? AND version=? AND active=1',(slug,version)).fetchone()
if not row: raise NotFound('Публикация недоступна')
return json.loads(row[0])
def versions(self,slug):
with self.connect() as db: rows=db.execute('SELECT version,active FROM publications WHERE slug=? ORDER BY version DESC',(slug,)).fetchall()
return [dict(r) for r in rows]
def unpublish(self,slug,version):
with self.connect() as db: db.execute('UPDATE publications SET active=0 WHERE slug=? AND version=?',(slug,version))
+65
View File
@@ -0,0 +1,65 @@
import json
from .paths import resolve_pointer
KINDS = {'table':'Таблица', 'cards':'Карточки', 'messages':'Сообщения', 'questionnaire':'Анкеты'}
def default_view(kind, paths):
return {'kind':kind, 'columns':[{'pointer':p,'label':p[1:].replace('~1','/').replace('~0','~')} for p in paths],
'page_size':25, 'search_pointer':'', 'category_pointer':'', 'questionnaire_mode':'columns',
'questions_pointer':'', 'question_pointer':'/question','answer_pointer':'/answer',
'title_pointer':paths[0] if paths else '', 'text_pointer':paths[0] if paths else '', 'author_pointer':'','date_pointer':''}
def validate_view(view, records):
errors=[]
if view.get('kind') not in KINDS: errors.append('Неизвестное представление')
if view.get('page_size') not in (10,25,50,100): errors.append('Размер страницы: 10, 25, 50 или 100')
pointers=[c['pointer'] for c in view.get('columns',[])]
if not pointers: errors.append('Выберите хотя бы одно поле')
roles=['title_pointer'] if view.get('kind')=='cards' else ['text_pointer'] if view.get('kind')=='messages' else []
if view.get('kind')=='questionnaire' and view.get('questionnaire_mode')=='pairs':
roles.append('questions_pointer')
arrays=[resolve_pointer(r,view.get('questions_pointer',''))[1] for r in records]
if not any(isinstance(a,list) for a in arrays): errors.append('Путь ответов должен указывать на массив')
for a in arrays:
if a is not None and (not isinstance(a,list) or any(not isinstance(x,dict) for x in a)):
errors.append('Ответы должны быть массивом объектов'); break
question_values=[resolve_pointer(x,view.get('question_pointer',''))[1] for a in arrays if isinstance(a,list) for x in a if isinstance(x,dict)]
if any(isinstance(value,(list,dict)) for value in question_values):
errors.append('Текст вопроса должен быть отдельным значением, а не массивом или объектом. Проверьте уровень записей при импорте: каждый участник должен быть отдельной записью; затем выберите его массив ответов и поле текста вопроса.')
for role in ('question_pointer','answer_pointer'):
if not view.get(role) or not any(resolve_pointer(x,view[role])[0] for a in arrays if isinstance(a,list) for x in a):
errors.append('Выберите поле текста вопроса внутри массива ответов' if role=='question_pointer' else 'Выберите поле значения ответа внутри массива ответов')
for role in roles:
if not view.get(role): errors.append(f'Назначьте поле {role}')
pointers += [view[k] for k in ('title_pointer','text_pointer','author_pointer','date_pointer','search_pointer','category_pointer') if view.get(k)]
for p in set(pointers):
if not any(resolve_pointer(r,p)[0] for r in records): errors.append(f'Поле отсутствует во всех записях: {p}')
for role in ('search_pointer','category_pointer'):
p=view.get(role)
if p:
for r in records:
found,value=resolve_pointer(r,p)
if found and value is not None and (not isinstance(value,str) if role=='search_pointer' else isinstance(value,(dict,list))):
errors.append('Поиск требует текстовое поле; категория — скалярное'); break
return errors
def display_field(record, pointer, label):
found,value=resolve_pointer(record,pointer)
state='missing' if not found else 'null' if value is None else 'value'
text='Нет значения' if not found else value if isinstance(value,str) else json.dumps(value,ensure_ascii=False)
value_type='missing' if not found else 'null' if value is None else 'boolean' if isinstance(value,bool) else 'number' if isinstance(value,(int,float)) else 'string' if isinstance(value,str) else 'array' if isinstance(value,list) else 'object'
return {'pointer':pointer,'label':label,'state':state,'type':value_type,'text':text}
def project_record(record, view, record_id):
fields=[display_field(record,c['pointer'],c['label']) for c in view['columns']]
if view['kind']=='questionnaire' and view.get('questionnaire_mode')=='pairs':
_,answers=resolve_pointer(record,view['questions_pointer'])
fields=[display_field(a,view['answer_pointer'],display_field(a,view['question_pointer'],'')['text']) for a in (answers or []) if isinstance(a,dict)] if isinstance(answers,list) else []
roles={}
for role in ('title','text','author','date'):
if view.get(role+'_pointer'):
roles[role]=display_field(record,view[role+'_pointer'],role)
# Only render roles consumed by this representation; never transmit unused role values.
allowed={'table':(), 'cards':('title',), 'messages':('text','author','date'), 'questionnaire':('title',)}[view['kind']]
role_fields={k:v for k,v in roles.items() if k in allowed}
return {'id':str(record_id),'fields':fields,'roles':{k:v['text'] for k,v in role_fields.items()},'role_fields':role_fields}
+32
View File
@@ -0,0 +1,32 @@
from pathlib import Path
from urllib.parse import urlencode
from jinja2 import Environment,FileSystemLoader,select_autoescape
from .query import select_records, categories
from .views import project_record, KINDS
from .repository import NotFound
ROOT=Path(__file__).parent/'web'
templates=Environment(loader=FileSystemLoader(ROOT/'templates'),autoescape=select_autoescape())
def render(name,**context):
return templates.get_template(name).render(**context)
def records_fragment(items,kind,base='',detail=False):
return render('records.html',items=items,kind=kind,base=base,detail=detail)
def public_page(publication,kind,base,page=1,search='',category=None,record_id=None,preview=False,live=False):
view=publication['views'].get(kind)
if view is None: raise NotFound('Представление не найдено')
if record_id:
if not record_id.isdigit() or not 1<=int(record_id)<=len(publication['records']): raise NotFound('Запись не найдена')
result={'items':[project_record(publication['records'][int(record_id)-1],view,record_id)],'total':1,'page':1,'has_next':False}
else: result=select_records(publication['records'],view,page,search,category)
def url(n):
params={'page':n}
if search: params['q']=search
if category is not None: params['category']=category
return base+'?'+urlencode(params)
return render('public.html',publication=publication,kind=kind,kind_label=KINDS[kind],view=view,result=result,
fragment=records_fragment(result['items'],kind,base,bool(record_id)),preview=preview,base=base,detail=bool(record_id),
previous=url(page-1) if page>1 else '',next=url(page+1) if result['has_next'] else '',search=search,
category=category,categories=categories(publication['records'],view),live=live)
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Binary file not shown.
Binary file not shown.
+107
View File
@@ -0,0 +1,107 @@
(() => {
const $ = selector => document.querySelector(selector);
const csrf = $('meta[name=csrf-token]').content;
const status = $('#status');
const show = (text, error=false) => { status.textContent=text;status.hidden=!text;status.className=error?'error':'notice saved'; };
async function api(url, method='GET', body) {
const options={method,headers:{'X-CSRF-Token':csrf}};
if(body instanceof FormData) options.body=body;
else if(body!==undefined){options.body=JSON.stringify(body);options.headers['Content-Type']='application/json';}
const response=await fetch(url,options), data=await response.json();
if(!response.ok)throw Error(data.error||'Не удалось выполнить запрос');
return data;
}
function node(tag,text,attrs={}) {const e=document.createElement(tag);if(text!==null)e.textContent=text;Object.assign(e,attrs);return e;}
let dirty=false;
window.addEventListener('beforeunload',e=>{if(dirty){e.preventDefault();e.returnValue='';}});
document.addEventListener('click',e=>{const link=e.target.closest('a[href]');if(dirty&&link&&!link.target){if(!confirm('Изменения не сохранены. Покинуть этот экран?'))e.preventDefault();else dirty=false;}});
document.querySelector('form[action="/logout"]')?.addEventListener('submit',()=>{dirty=false;});
const importForm=$('#import-form');
if(importForm) {
const save=$('#import-save'),inspect=$('#inspect'),result=$('#import-result');
let generation=0,needsRootChoice=false;
importForm.addEventListener('change',e=>{
if(e.target.name==='root_confirmed'){save.disabled=needsRootChoice&&!e.target.checked;return;}
dirty=true;generation++;save.disabled=true;result.replaceChildren();needsRootChoice=false;$('#root-choice').hidden=true;importForm.elements.root_confirmed.checked=false;
if(e.target.name==='file') {const file=e.target.files[0];if(file)importForm.elements.title.value=file.name.replace(/\.[^.]+$/,'');importForm.elements.pointer.replaceChildren(new Option('Весь документ / корневой массив',''));}
});
inspect.onclick=async()=>{
const current=generation;save.disabled=true;result.replaceChildren();inspect.disabled=true;
try {
const data=await api('/api/admin/inspect','POST',new FormData(importForm));
if(current!==generation)return;
const selected=data.record_pointer??importForm.elements.pointer.value;
importForm.elements.pointer.replaceChildren(new Option('Весь документ / корневой массив',''),...data.arrays.filter(Boolean).map(p=>new Option(p,p)));
importForm.elements.pointer.value=selected;
result.append(node('h2',`${data.count} записей · ${data.fields.length} полей`));
for(const warning of data.warnings)result.append(node('p',warning));
const preview=node('div',null);preview.innerHTML=data.html;result.append(preview);needsRootChoice=data.choose_record_level;$('#root-choice').hidden=!needsRootChoice;save.disabled=needsRootChoice&&!importForm.elements.root_confirmed.checked;show(needsRootChoice?'В JSON найден вложенный список записей. Выберите его в поле «Записи внутри JSON» и нажмите «Проверить файл» ещё раз. Сейчас весь документ считается одной записью.':'Файл проверен');
}catch(error){show(error.message,true);}finally{inspect.disabled=false;}
};
importForm.onsubmit=async e=>{e.preventDefault();if(save.disabled)return;save.disabled=true;
try{const data=await api('/api/admin/import','POST',new FormData(importForm));dirty=false;location.href='/admin/datasets/'+data.id;}
catch(error){show(error.message,true);save.disabled=false;}
};
}
const source=$('#editor-data');
if(source) {
const data=JSON.parse(source.textContent),kind=$('#kind'),enabled=$('#enabled'),controls=$('#view-controls');
kind.value=Array.from(kind.options).find(option=>data.views[option.value])?.value||'table';
let previewTimer,previewSequence=0,editSequence=0;
const payload=()=>({revision:data.revision,title:$('#title').value,description:$('#description').value,slug:$('#slug').value,views:data.views});
function changed(){editSequence++;previewSequence++;dirty=true;$('#dirty').hidden=false;$('#publish').disabled=true;clearTimeout(previewTimer);previewTimer=setTimeout(preview,200);}
async function preview(){const seq=++previewSequence;$('#preview-kind').textContent=kind.selectedOptions[0].text;$('#full-preview').href=`/preview/${data.id}/${kind.value}`;
try{const result=await api(`/api/admin/datasets/${data.id}/preview?kind=${kind.value}`,'POST',payload());if(seq!==previewSequence)return;
const target=$('#live-preview');target.replaceChildren();
if(result.errors.length)target.append(node('p',result.errors.join('\n'),{className:'muted'}));else target.innerHTML=result.html;
}catch(error){if(seq===previewSequence)show(error.message,true);}
}
function select(label,key,options){const wrapper=node('label',label),input=node('select',null);input.setAttribute('aria-label',label);for(const [text,value] of options)input.add(new Option(text,value));input.value=data.views[kind.value][key]??'';
input.onchange=()=>{data.views[kind.value][key]=key==='page_size'?Number(input.value):input.value;changed();if(['questionnaire_mode','questions_pointer'].includes(key))renderControls();};wrapper.append(input);controls.append(wrapper);return input;}
const options=()=>[['Не выбрано',''],...data.fields.map(f=>[`${f.label} (${f.pointer})`,f.pointer])];
function renderControls(){controls.replaceChildren();const view=data.views[kind.value];enabled.checked=!!view;if(!view){preview();return;}
select('Записей на странице','page_size',[10,25,50,100].map(n=>[String(n),n]));
select('Текстовое поле для поиска','search_pointer',options());select('Поле категорий','category_pointer',options());
const roles={table:[],cards:[['Заголовок','title_pointer']],messages:[['Текст сообщения','text_pointer'],['Автор','author_pointer'],['Дата','date_pointer']],questionnaire:[['Заголовок участника','title_pointer']]};
for(const [label,key] of roles[kind.value])select(label,key,options());
if(kind.value==='questionnaire'){
select('Структура анкеты','questionnaire_mode',[['Колонки с ответами','columns'],['Массив вопросов и ответов','pairs']]);
if(view.questionnaire_mode==='pairs'){
select('Массив ответов','questions_pointer',options());
const q=select('Поле вопроса внутри ответа','question_pointer',[[view.question_pointer,view.question_pointer]]),a=select('Поле ответа','answer_pointer',[[view.answer_pointer,view.answer_pointer]]);
api(`/api/admin/datasets/${data.id}/fields?pointer=${encodeURIComponent(view.questions_pointer)}`).then(result=>{
if(!q.isConnected)return;
for(const [input,key] of [[q,'question_pointer'],[a,'answer_pointer']]){input.replaceChildren(new Option('Не выбрано',''),...(key==='question_pointer'?result.scalar_fields:result.fields).map(p=>new Option(p,p)));input.value=view[key];}
}).catch(error=>show(error.message,true));
}
}
if(kind.value==='questionnaire'&&view.questionnaire_mode==='pairs'){controls.append(node('p','Подписи вопросов и ответы берутся из выбранного массива.',{className:'muted'}));preview();return;}
controls.append(node('h2','Поля и подписи'));
for(const field of data.fields){const row=node('label',null,{className:'check-label'}),check=node('input',null,{type:'checkbox',checked:view.columns.some(c=>c.pointer===field.pointer)});
row.append(check,node('span',field.label+(field.missing?` · нет в ${field.missing} записях`:''),{title:field.pointer}));
check.onchange=()=>{if(check.checked)view.columns.push({pointer:field.pointer,label:field.label});else view.columns=view.columns.filter(c=>c.pointer!==field.pointer);changed();renderControls();};controls.append(row);}
controls.append(node('h3','Порядок и подписи'));
view.columns.forEach((column,i)=>{
const row=node('div',null,{className:'field-order'}),label=node('label','Подпись поля'),input=node('input',null,{value:column.label});
input.oninput=()=>{column.label=input.value;changed();};label.append(input);row.append(label);
for(const [text,delta] of [['↑',-1],['↓',1]]){const target=i+delta,b=node('button',text,{type:'button',className:'secondary',disabled:target<0||target>=view.columns.length});b.setAttribute('aria-label',delta<0?'Переместить поле выше':'Переместить поле ниже');b.onclick=()=>{[view.columns[i],view.columns[target]]=[view.columns[target],view.columns[i]];changed();renderControls();};row.append(b);}controls.append(row);
});preview();
}
kind.onchange=renderControls;enabled.onchange=()=>{if(enabled.checked)data.views[kind.value]=structuredClone(data.defaults[kind.value]);else delete data.views[kind.value];changed();renderControls();};
for(const id of ['title','description','slug'])$('#'+id).oninput=changed;
$('#save-draft').onclick=async()=>{const button=$('#save-draft'),savedSequence=editSequence;button.disabled=true;
try{const result=await api(`/api/admin/datasets/${data.id}`,'PUT',payload());data.revision=result.revision;dirty=editSequence!==savedSequence;$('#dirty').hidden=!dirty;$('#publish').disabled=dirty;show(dirty?'Черновик сохранён. Более поздние изменения ещё не сохранены.':'Черновик сохранён');}
catch(error){show(error.message,true);}finally{button.disabled=false;}
};
$('#configure-questionnaire')?.addEventListener('click',async e=>{
if(dirty){show('Сначала сохраните изменения черновика',true);return;}
const button=e.currentTarget;button.disabled=true;
try{await api(`/api/admin/datasets/${data.id}/configure-questionnaire`,'POST',{revision:data.revision});location.reload();}
catch(error){show(error.message,true);button.disabled=false;}
});
$('#publish').onclick=async()=>{if(dirty){show('Сначала сохраните черновик',true);return;}try{await api(`/api/admin/datasets/${data.id}/publish`,'POST',{revision:data.revision});location.href=`/admin/datasets/${data.id}/versions`;}catch(error){show(error.message,true);}};
renderControls();
}
$('#publish-version')?.addEventListener('click',async e=>{const b=e.currentTarget;b.disabled=true;try{await api(`/api/admin/datasets/${b.dataset.id}/publish`,'POST',{revision:Number(b.dataset.revision)});location.reload();}catch(error){show(error.message,true);b.disabled=false;}});
document.querySelectorAll('[data-unpublish]').forEach(button=>button.onclick=async()=>{button.disabled=true;try{await api(`/api/admin/datasets/${button.dataset.id}/unpublish/${button.dataset.unpublish}`,'POST',{});location.reload();}catch(error){show(error.message,true);button.disabled=false;}});
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="5" fill="#305bdd"/><path d="M12 8H8v16h4m8-16h4v16h-4" fill="none" stroke="white" stroke-width="2.5"/></svg>

After

Width:  |  Height:  |  Size: 201 B

+31
View File
@@ -0,0 +1,31 @@
(() => {
const root=document.querySelector('[data-live-collection]');if(!root)return;
const form=root.querySelector('.filters'),pagination=root.querySelector('.live-pagination');if(!pagination)return;
let page=Number(root.dataset.page)||1,controller,timer,generation=0,busy=false;
const button=pagination.querySelector('[data-load-more]');
const error=document.createElement('div');error.className='error';error.hidden=true;pagination.before(error);
async function load(reset=false){
if(!reset&&busy)return;
if(reset)controller?.abort();
const current=++generation;controller=new AbortController();busy=true;if(button)button.disabled=true;
const url=new URL(location.href);url.searchParams.set('fragment','1');url.searchParams.set('page',reset?'1':String(page+1));
for(const key of ['q','category']){const value=form?.elements[key]?.value||'';if(value)url.searchParams.set(key,value);else url.searchParams.delete(key);}
try{
const response=await fetch(url,{signal:controller.signal}),data=await response.json();if(!response.ok)throw Error(data.error||'Не удалось загрузить записи');if(current!==generation)return;
const holder=document.createElement('div');holder.innerHTML=data.html;
const existing=root.querySelector('.lab-records'),incoming=holder.querySelector('.lab-records');
if(reset)existing.replaceWith(incoming);
else {const target=existing.querySelector('tbody')||existing.querySelector('.record-grid');incoming.querySelectorAll('.record').forEach(r=>target.append(r));}
page=data.page;root.querySelector('.collection-meta b').textContent=data.total;
pagination.querySelector('[data-loaded]').textContent=`Показано ${root.querySelectorAll('.record').length} из ${data.total}`;
if(button)button.hidden=!data.has_next;pagination.querySelector('[data-complete]').hidden=data.has_next;
error.hidden=true;
}catch(e){if(e.name!=='AbortError'){error.textContent=e.message;error.hidden=false;}}
finally{if(current===generation){busy=false;if(button)button.disabled=false;}}
}
form?.addEventListener('submit',e=>{e.preventDefault();clearTimeout(timer);load(true);});
form?.elements.q?.addEventListener('input',()=>{clearTimeout(timer);controller?.abort();generation++;busy=true;if(button)button.disabled=true;timer=setTimeout(()=>load(true),250);});
form?.elements.category?.addEventListener('change',()=>{clearTimeout(timer);load(true);});
form?.querySelector('[data-reset]')?.addEventListener('click',e=>{e.preventDefault();clearTimeout(timer);for(const key of ['q','category'])if(form.elements[key])form.elements[key].value='';load(true);});
button?.addEventListener('click',()=>load());
})();
+65
View File
@@ -0,0 +1,65 @@
@font-face{font-family:Lab;src:url('/static/Roboto-Regular.ttf');font-display:swap}
@font-face{font-family:Lab;src:url('/static/Roboto-Bold.ttf');font-weight:700;font-display:swap}
:root{--paper:#f6f7f9;--ink:#20242c;--muted:#626b79;--blue:#305bdd;--line:#dfe3e9;--white:#fff;--soft:#eef2ff}
*{box-sizing:border-box}
body{margin:0;background:var(--paper);color:var(--ink);font:14px/1.55 Lab,system-ui,sans-serif}
a{color:var(--blue);text-decoration:none}a:hover{text-decoration:underline}
button,a,input,select{outline-offset:4px}button,input,select{font:inherit}
button,.button{display:inline-flex;align-items:center;justify-content:center;gap:16px;background:var(--blue);color:#fff;border:1px solid var(--blue);padding:10px 17px;border-radius:5px;font-weight:700;cursor:pointer}
button:hover,.button:hover{background:#2349be;text-decoration:none}
input,select{width:100%;min-width:0;background:#fff;border:1px solid #bbc3d0;border-radius:5px;padding:10px 12px;color:var(--ink)}
input:focus,select:focus{outline:2px solid #aebdf7;border-color:var(--blue)}
label{display:flex;flex-direction:column;gap:7px;font-size:12px;font-weight:700}
h1,h2,p{margin-top:0}h1{font-size:32px;line-height:1.2;letter-spacing:-.8px;margin-bottom:12px}h2{font-size:20px;line-height:1.35;letter-spacing:-.3px}
.topbar{height:64px;padding:0 40px;display:flex;align-items:center;justify-content:space-between;gap:24px;border-bottom:1px solid var(--line);background:#fff}
.brand{display:flex;align-items:center;gap:10px;font-size:17px;font-weight:700;letter-spacing:-.4px;color:var(--ink)}
.brand:hover{text-decoration:none}.brand-mark{display:grid;place-items:center;width:28px;height:28px;background:var(--blue);color:#fff;border-radius:4px;font:700 16px monospace;letter-spacing:-3px;padding-right:3px}
.top-note{font-size:12px;color:var(--muted);margin-right:auto;border-left:1px solid var(--line);padding-left:24px}.quiet-link{font-size:12px;color:var(--muted)}
.site-footer{padding:20px 40px;display:flex;justify-content:space-between;color:var(--muted);font-size:11px;border-top:1px solid var(--line);background:#fff}
.eyebrow{font-size:11px;letter-spacing:.6px;font-weight:700;color:var(--muted);margin-bottom:10px}
.lead{font-size:15px;max-width:760px;color:var(--muted);line-height:1.7}
.muted{color:var(--muted);font-size:12px}.error{padding:12px;background:#fff3f3;border-left:3px solid #c84444;color:#922b2b;border-radius:3px}
.login-layout{min-height:calc(100vh - 125px);display:grid;place-items:center;padding:56px 24px}
.login-card{width:100%;max-width:420px;padding:34px;background:#fff;border:1px solid var(--line);border-radius:8px}
.login-card h1{font-size:26px;margin:18px 0 8px}.login-card form{display:flex;flex-direction:column;gap:20px;margin-top:28px}.login-card button{margin-top:4px}.login-foot{font-size:12px;color:var(--muted);margin:24px 0 0;padding-top:20px;border-top:1px solid var(--line)}
.home{max-width:1020px;padding:80px 40px 100px;margin:auto;min-height:calc(100vh - 125px)}
.home h1{font-size:44px;max-width:600px;margin-bottom:20px}.home-intro{padding-bottom:36px;border-bottom:1px solid var(--line)}
.home-guide{display:grid;grid-template-columns:1fr 1fr;gap:56px;margin-top:40px}.home-guide h2{font-size:16px}.home-guide p{color:var(--muted)}
.format-list{display:flex;gap:8px;flex-wrap:wrap;margin-top:24px}.format-list span{font:11px monospace;border:1px solid var(--line);padding:5px 9px;border-radius:3px;color:var(--muted);background:#fff}
.public-main{max-width:1360px;padding:32px 40px 64px;margin:auto;min-height:calc(100vh - 125px)}
.breadcrumb{font-size:12px;color:var(--muted)}.page-heading{display:flex;justify-content:space-between;gap:24px;margin-top:30px}.page-heading h1{font-size:32px}.page-heading .lead{margin-bottom:12px}
.version-badge{white-space:nowrap;border:1px solid var(--line);background:#fff;border-radius:4px;padding:5px 9px;font:11px/1.5 monospace;height:fit-content}
.collection-meta{display:flex;flex-wrap:wrap;gap:20px;padding:10px 0 24px;color:var(--muted);font-size:12px}.collection-meta b{color:var(--ink);font-weight:700}
.filters{display:flex;align-items:end;gap:12px;padding:16px 0;border-top:1px solid var(--line);margin-bottom:14px}.filters label{flex:1;max-width:360px}.filters a{font-size:12px;padding:10px}
.table-scroll{max-width:100%;overflow:auto;border:1px solid var(--line);border-radius:5px;background:#fff}
table{border-collapse:collapse;width:100%;text-align:left;font-size:13px}th{background:#f0f2f6;font-size:11px;color:#485365;font-weight:700;white-space:nowrap}
th,td{padding:11px 16px;border-bottom:1px solid #e8ebf0}td{white-space:pre-wrap;overflow-wrap:anywhere;min-width:100px;max-width:440px}tbody tr:last-child td{border-bottom:0}tbody tr:hover{background:#f7f9ff}
.row-index{font:11px monospace;color:#737e8d;width:48px;min-width:48px;border-right:1px solid #e8ebf0}.detail-link{font-size:12px;white-space:nowrap}
.record-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.record-grid.single{grid-template-columns:1fr}
.record-card{background:#fff;border:1px solid var(--line);border-radius:6px;padding:24px;min-width:0}.record-card h2{overflow-wrap:anywhere;margin:12px 0 20px}.record-top{display:flex;justify-content:space-between;gap:12px;align-items:center;margin-bottom:18px}.record-top .eyebrow{margin:0;font:10px monospace;letter-spacing:.4px}
.record-card dl{margin:0}.field-pair{padding:12px 0;border-top:1px solid #edf0f4}.field-pair dt{color:var(--muted);font-size:11px;margin-bottom:4px}.field-pair dd{margin:0;white-space:pre-wrap;overflow-wrap:anywhere}
.field[data-value-state=missing],.field[data-value-state=null]{color:#747d8a;font-style:italic}
.cards .record-card{border-top:3px solid #3d63d8}.cards .field-pair{display:grid;grid-template-columns:1fr 1.2fr;gap:10px}.cards .field-pair dt{margin:2px 0}
.messages .record-grid{gap:0;max-width:840px;margin:0 auto;border:1px solid var(--line);border-radius:6px;overflow:hidden}
.record-card.messages{border:0;border-bottom:1px solid var(--line);border-radius:0;padding:24px 30px}.record-card.messages:last-child{border-bottom:0}
.messages .record-top{margin-bottom:12px}.messages .record-top .eyebrow{font-size:10px}.message-author{display:flex;align-items:center;gap:12px;margin-bottom:16px}.avatar{width:36px;height:36px;border-radius:50%;background:#edf1fb;color:#3456ab;display:grid;place-items:center;font-size:13px;font-weight:700;flex-shrink:0}
.message-text{white-space:pre-wrap;overflow-wrap:anywhere;font-size:15px;line-height:1.75;margin:0 0 16px 48px}.messages dl{margin-left:48px;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 20px}.messages .field-pair{padding:9px 0}.messages .field-pair dd{font-size:12px;color:#475265}
.questionnaire .record-grid{max-width:860px;margin:auto;gap:28px}.record-card.questionnaire{border-radius:2px;padding:30px 36px;border-top:3px solid #555d6c}.questionnaire .record-top{border-bottom:1px solid var(--line);padding-bottom:18px}.questionnaire .record-card h2{font-size:24px}.questionnaire dl{counter-reset:question}.questionnaire .field-pair{position:relative;padding:20px 0 20px 38px}.questionnaire .field-pair:before{counter-increment:question;content:counter(question,decimal-leading-zero);position:absolute;left:0;top:22px;color:#838b97;font:11px monospace}.questionnaire .field-pair dt{font-size:13px;color:#454d59;margin-bottom:10px}.questionnaire .field-pair dd{padding:11px 14px;background:#f5f6f8;border-left:2px solid #d2d7e0;font-size:14px}
.pagination{display:flex;justify-content:space-between;align-items:center;margin-top:24px;font-size:12px}.pagination a{border:1px solid var(--line);border-radius:4px;padding:8px 13px;background:#fff}.back-link{display:inline-block;margin-top:24px}.empty{padding:44px 24px;text-align:center;border:1px dashed #bdc6d3;border-radius:5px;color:var(--muted)}
.lab-records{font-family:Lab,system-ui,sans-serif;color:var(--ink);line-height:1.55;width:100%}
@media(max-width:1000px){.record-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.catalog-grid{grid-template-columns:minmax(100px,2fr) .7fr 1fr;gap:10px}.catalog-formats{display:none}}
@media(max-width:750px){.topbar{height:58px;padding:0 18px;gap:16px}.top-note{display:none}.quiet-link{font-size:11px}.brand{font-size:15px}.site-footer{padding:18px;font-size:10px}.login-layout{padding:30px 18px;min-height:calc(100vh - 115px)}.login-card{padding:26px 22px}.home{padding:44px 22px}.home h1{font-size:33px}.home-guide{grid-template-columns:1fr;gap:24px}.public-main{padding:24px 18px 40px}.page-heading h1{font-size:27px}.page-heading{flex-direction:column;gap:4px;margin-top:22px}.version-badge{align-self:flex-start}.collection-meta{padding-top:18px;gap:12px}.record-grid{grid-template-columns:1fr}.record-card{padding:20px}.record-card.messages{padding:20px}.record-card.questionnaire{padding:22px}.message-text,.messages dl{margin-left:0}.filters{flex-wrap:wrap}.filters label{flex-basis:100%;max-width:none}.filters button{flex:1}.catalog-grid{grid-template-columns:minmax(80px,1fr) auto;gap:8px;min-height:62px}.catalog-count{display:none}.catalog-head .catalog-count{display:none}.catalog-title{font-size:12px}.status{font-size:10px}.editor-preview{padding:12px}.editor-preview-head{flex-wrap:wrap}.admin-brand{padding-bottom:4px}.workspace-label{display:none}}
.live-public{width:100%;min-width:0;color:var(--ink);font:14px/1.55 Lab,system-ui,sans-serif}.live-pagination{gap:16px;flex-wrap:wrap}
html.static-site{scrollbar-gutter:stable}
[hidden]{display:none!important}
/* Server-rendered teacher workspace. */
.admin-body{display:flex;min-height:100vh}.admin-sidebar{width:240px;flex-shrink:0;background:#fff;border-right:1px solid var(--line);padding:30px 22px;position:sticky;top:0;height:100vh;display:flex;flex-direction:column}.admin-sidebar nav{display:grid;gap:8px;margin-top:30px}.admin-sidebar nav a{padding:10px 12px;color:var(--ink);border-radius:4px}.admin-sidebar nav a.active{background:var(--soft);color:var(--blue);font-weight:700}.sidebar-bottom{margin-top:auto}.sidebar-bottom form{margin:0}.text-button{background:none;border:none;color:var(--muted);padding:0;font-weight:400}.text-button:hover{background:none;color:var(--blue)}
.admin-main{padding:36px;flex:1;min-width:0}.admin-main>#status:not([hidden]){margin-bottom:20px}.admin-main h1{font-size:28px}.admin-heading{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-bottom:24px}.dataset-table td{padding:16px;min-width:60px}.dataset-table td:first-child{min-width:220px}.dataset-table td:last-child{min-width:80px}.dataset-table .catalog-title{font-size:14px}.toolbar{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin:24px 0}.toolbar a{font-size:13px}.toolbar button{font-size:13px}.secondary{background:#fff;color:var(--blue);border-color:var(--line)}.secondary:hover{background:var(--soft)}button:disabled{opacity:.5;cursor:default}.form-panel{background:#fff;border:1px solid var(--line);border-radius:6px;padding:22px;margin:20px 0;display:grid;gap:18px}.form-panel h2{font-size:18px;margin:0}.form-panel h3{font-size:13px;font-weight:400;color:var(--muted);margin-bottom:0}.form-panel label{font-size:12px}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.file-drop{border:1px dashed #aebbd0;padding:26px;background:#f9faff}.file-drop input{border:0;background:none;padding:10px 0 0}.check-label{display:flex;flex-direction:row;align-items:center;gap:10px;font-weight:400}.check-label input{width:17px;height:17px;flex-shrink:0;accent-color:var(--blue)}.check-label span{overflow-wrap:anywhere}.form-panel textarea{font:inherit;width:100%;resize:vertical;padding:10px;border:1px solid #bbc3d0;border-radius:5px}.editor-layout{display:grid;grid-template-columns:360px minmax(0,1fr);gap:28px;align-items:start}.editor-settings{max-height:calc(100vh - 220px);overflow:auto;padding-right:10px}.editor-settings .form-panel:first-child{margin-top:0}.editor-preview-panel{position:sticky;top:24px;min-width:0}.editor-preview-head{margin-bottom:16px}#view-controls{display:grid;gap:16px}.field-order{display:flex;align-items:end;gap:6px}.field-order label{flex:1;min-width:0}.field-order button{padding:10px;min-width:32px}.editor-preview{min-height:350px}.version-links{display:flex;gap:24px;flex-wrap:wrap;font-size:13px}.version-links strong{min-width:100px}#import-form{max-width:780px}#import-result{margin-top:28px}.form-panel .toolbar{margin:0}
@media(max-width:1100px){.admin-sidebar{width:200px}.admin-main{padding:26px}.editor-layout{grid-template-columns:320px minmax(0,1fr);gap:18px}}
@media(max-width:850px){.admin-body{display:block}.admin-sidebar{position:static;width:auto;height:auto;padding:18px;border-right:0;border-bottom:1px solid var(--line)}.admin-sidebar nav{display:flex;flex-wrap:wrap;margin-top:10px}.admin-sidebar>.muted{display:none}.sidebar-bottom{display:flex;gap:18px;align-items:center;margin-top:14px}.sidebar-bottom p{margin:0}.admin-main{padding:22px 18px}.admin-heading{align-items:start;flex-direction:column}.editor-layout{display:flex;flex-direction:column}.editor-preview-panel{position:static;order:-1;width:100%}.editor-settings{max-height:none;overflow:visible;padding:0;width:100%}.form-grid{grid-template-columns:1fr}.admin-main h1{font-size:24px}.form-panel{padding:18px}.toolbar{gap:12px}.version-links{flex-direction:column;gap:8px}}
.catalog-file{font-size:11px;color:var(--muted);margin-top:4px}.status{font-size:11px;border-radius:3px;padding:4px 8px;background:#eef0f4;color:#657083}.status.published{background:#edf5ef;color:#36744c}.notice{padding:12px 16px;background:#edf3ff;border-left:3px solid var(--blue);font-size:13px}.editor-preview-head{display:flex;justify-content:space-between;align-items:center;gap:12px}.editor-preview-head h2{font-size:16px;margin:0}.editor-preview-head span,.preview-foot{font-size:11px;color:var(--muted)}.editor-preview{border:1px solid var(--line);border-radius:6px;background:#f6f7fa;padding:18px;overflow:hidden}.editor-preview .record-grid{grid-template-columns:1fr}.preview-foot{margin-top:12px;line-height:1.6}
+21
View File
@@ -0,0 +1,21 @@
<!doctype html><html lang="ru" class="static-site"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Кабинет · Parser Lab</title><link rel="icon" type="image/png" href="/static/favicon.png"><link rel="stylesheet" href="/static/site.css"><meta name="csrf-token" content="{{ csrf }}"><script defer src="/static/admin.js?v=questionnaire-autodetect-1"></script></head>
<body class="admin-body"><aside class="admin-sidebar"><a class="brand" href="/admin"><span class="brand-mark">[ ]</span>Parser Lab</a><p class="muted">Кабинет преподавателя</p><nav><a class="{{ 'active' if screen!='import' }}" href="/admin">Мои коллекции</a><a class="{{ 'active' if screen=='import' }}" href="/admin/import">Импорт файла</a></nav><div class="sidebar-bottom"><a href="/">Учебный сайт ↗</a><p class="muted">{{ username }}</p><form method="post" action="/logout"><input type="hidden" name="csrf" value="{{ csrf }}"><button class="text-button" type="submit">Выйти</button></form></div></aside>
<main class="admin-main" data-screen="{{ screen }}"><div id="status" role="status" hidden></div>
{% if screen=='catalog' %}
<div class="admin-heading"><div><div class="eyebrow">КАБИНЕТ / КОЛЛЕКЦИИ</div><h1>Коллекции данных</h1><p class="muted">Всего коллекций: {{ datasets|length }}</p></div><a class="button" href="/admin/import">Загрузить данные</a></div>
<div class="table-scroll"><table class="dataset-table"><thead><tr><th>Название</th><th>Записи</th><th>Представления</th><th>Статус</th><th></th></tr></thead><tbody>{% for d in datasets %}<tr><td><div class="catalog-title">{{ d.title }}</div><div class="catalog-file">{{ d.filename }}</div></td><td>{{ d.records|length }}</td><td>{{ d.views|length }}</td><td><span class="status {{ 'published' if active[d.id] }}">{{ 'Опубликовано' if active[d.id] else 'Черновик' }}</span></td><td><a href="/admin/datasets/{{ d.id }}" aria-label="Открыть {{ d.title }}">Открыть</a></td></tr>{% else %}<tr><td colspan="5">Коллекций пока нет. Загрузите первый набор данных.</td></tr>{% endfor %}</tbody></table></div>
{% elif screen=='import' %}
<div class="eyebrow">КОЛЛЕКЦИИ / ИМПОРТ</div><h1>Импорт данных</h1><p class="muted">Выберите файл, проверьте структуру и сохраните коллекцию.</p>
<form id="import-form" class="form-panel"><label class="file-drop">CSV, JSON или JSONL · до {{ (max_bytes/1024/1024)|int }} МиБ<input name="file" type="file" accept=".csv,.json,.jsonl" required></label><label>Название коллекции<input name="title" value="{{ replace.title if replace else '' }}" required></label><div class="form-grid"><label>Кодировка CSV<select name="encoding" aria-label="Кодировка CSV"><option value="utf-8-sig">UTF-8</option><option value="cp1251">Windows-1251</option></select></label><label>Разделитель CSV<select name="delimiter" aria-label="Разделитель CSV"><option value="">Автоматически</option><option value=",">Запятая</option><option value=";">Точка с запятой</option><option value="&#9;">Табуляция</option></select></label></div><label>Записи внутри JSON<select name="pointer" aria-label="Записи внутри JSON"><option value="">Весь документ / корневой массив</option></select></label>
{% if replace %}<input type="hidden" name="replace_id" value="{{ replace.id }}"><input type="hidden" name="revision" value="{{ replace.revision }}"><p class="notice">Замена файла сбросит настройки представлений. Опубликованные версии сохранятся.</p>{% endif %}
<label id="root-choice" class="check-label notice" hidden><input type="checkbox" name="root_confirmed" value="1">Использовать весь документ как одну запись</label><div class="toolbar"><button id="inspect" type="button">Проверить файл</button><button id="import-save" type="submit" disabled>Сохранить и настроить →</button><a href="/admin">К коллекциям</a></div></form><div id="import-result"></div>
{% elif screen=='edit' %}
<div class="eyebrow">КОЛЛЕКЦИИ / РЕДАКТОР</div><h1>{{ draft.title }}</h1><p class="muted">{{ draft.records|length }} записей · {{ draft.filename }}</p>
{% if questionnaire_repair %}<section class="notice" id="questionnaire-repair"><strong>Найдены анкеты: {{ questionnaire_repair.records|length }} участников.</strong><p>{% if questionnaire_repair.pointer %}Участники находятся внутри {{ questionnaire_repair.pointer }}. Можно разделить их на отдельные записи и автоматически назначить вопросы и ответы. Настройки представлений черновика будут пересозданы; опубликованные версии сохранятся.{% else %}Можно автоматически назначить заголовки участников, вопросы и ответы.{% endif %}</p><button type="button" id="configure-questionnaire">Настроить анкеты автоматически</button></section>{% endif %}<div class="toolbar editor-toolbar"><button id="save-draft">Сохранить черновик</button><button id="publish">Опубликовать новую версию</button><a id="full-preview" target="_blank" rel="noopener">Предпросмотр ↗</a><a href="/admin/datasets/{{ draft.id }}/versions">Публикации</a><span id="dirty" class="muted" hidden>Есть несохранённые изменения</span></div>
<div class="editor-layout"><div class="editor-settings"><section class="form-panel"><label>Представление<select id="kind" aria-label="Представление">{% for k,label in kinds.items() %}<option value="{{ k }}">{{ label }}</option>{% endfor %}</select></label><label class="check-label"><input id="enabled" type="checkbox">Включить это представление</label><div id="view-controls"></div></section>
<section class="form-panel"><h2>О коллекции</h2><label>Название<input id="title" value="{{ draft.title }}"></label><label>Описание коллекции<textarea id="description" rows="3">{{ draft.description }}</textarea></label><label>Адрес коллекции<input id="slug" value="{{ draft.slug }}"></label><a href="/admin/import?replace={{ draft.id }}">Заменить исходный файл</a></section></div>
<section class="editor-preview-panel"><div class="editor-preview-head"><h2>Предпросмотр изменений</h2><span id="preview-kind"></span></div><div class="editor-preview" id="live-preview"></div><p class="preview-foot">Первые 5 записей · Опубликованные данные изменятся только после создания новой версии.</p></section></div><script id="editor-data" type="application/json">{{ editor_data|tojson }}</script>
{% elif screen=='versions' %}
<div class="eyebrow">КОЛЛЕКЦИИ / ПУБЛИКАЦИИ</div><h1>{{ draft.title }}</h1><div class="toolbar"><a href="/admin/datasets/{{ draft.id }}">← Настройка</a><button id="publish-version" data-id="{{ draft.id }}" data-revision="{{ draft.revision }}">Опубликовать новую версию</button></div>
{% for v in versions %}<section class="form-panel"><h2>Версия {{ v.version }} · {{ 'Доступна' if v.active else 'Снята с публикации' }}</h2>{% for k in v.kinds %}<div class="version-links"><strong>{{ kinds[k] }}</strong><a href="/html/{{ draft.slug }}/v/{{ v.version }}/{{ k }}" target="_blank">HTML · BeautifulSoup ↗</a><a href="/interactive/{{ draft.slug }}/v/{{ v.version }}/{{ k }}" target="_blank">Дозагрузка · Playwright ↗</a></div>{% endfor %}{% if v.active %}<button class="secondary" data-unpublish="{{ v.version }}" data-id="{{ draft.id }}">Снять эту версию с публикации</button>{% endif %}</section>{% else %}<p class="empty">Опубликованных версий пока нет.</p>{% endfor %}
{% endif %}</main></body></html>
+3
View File
@@ -0,0 +1,3 @@
<!DOCTYPE html>
<html lang="ru" class="static-site"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>{{ title|default('Parser Lab') }}</title><link rel="stylesheet" href="/static/site.css"><link rel="icon" type="image/png" href="/static/favicon.png"></head>
<body>{% include 'header.html' %}{% block content %}{% endblock %}{% include 'footer.html' %}</body></html>
@@ -0,0 +1,7 @@
<main class="public-main" {% if live %}data-live-collection data-page="{{ result.page }}"{% endif %}><a class="breadcrumb" href="/">Parser Lab / Коллекции</a><section class="page-heading"><div><div class="eyebrow">{{ 'ПРЕДПРОСМОТР · ЧЕРНОВИК' if preview else 'УЧЕБНАЯ КОЛЛЕКЦИЯ' }}</div><h1>{{ publication.title }}</h1><p class="lead">{{ publication.description }}</p></div><div class="version-badge">{{ 'Черновик' if preview else 'Версия ' ~ publication.version }}</div></section>
<section class="collection-meta"><span><b>{{ result.total }}</b> {{ 'запись' if detail else 'записей' }}</span><span>{{ kind_label }}</span></section>
{% if not detail and (view.search_pointer or view.category_pointer) %}<form class="filters" method="get" action="{{ base }}">{% if view.search_pointer %}<label>Поиск<input aria-label="Поиск в коллекции" name="q" value="{{ search }}" placeholder="Найти в коллекции"></label>{% endif %}{% if view.category_pointer %}<label>Категория<select name="category" aria-label="Категория"><option value="">Все значения</option>{% for value,label in categories %}<option value="{{ value }}" {{ 'selected' if category==value else '' }}>{{ label }}</option>{% endfor %}</select></label>{% endif %}<button type="submit">Применить</button><a data-reset href="{{ base }}">Сбросить</a></form>{% endif %}
{{ fragment|safe }}
{% if message %}<div class="error" role="alert">{{ message }}</div>{% endif %}
{% if live and not detail %}<nav class="pagination live-pagination" aria-label="Дозагрузка"><span data-loaded>Показано {{ result['items']|length }} из {{ result.total }}</span><button type="button" data-load-more {{ 'hidden' if not result.has_next }}>Загрузить ещё</button><span class="muted" data-complete {{ 'hidden' if result.has_next }}>Все записи загружены</span></nav>
{% elif not detail %}<nav class="pagination" aria-label="Страницы">{% if previous %}<a href="{{ previous }}" rel="prev">← Назад</a>{% else %}<span></span>{% endif %}<span>Страница {{ result.page }}</span>{% if next %}<a href="{{ next }}" rel="next">Следующая →</a>{% else %}<span></span>{% endif %}</nav>{% else %}<a class="back-link" href="{{ base }}">← Все записи</a>{% endif %}</main>
+1
View File
@@ -0,0 +1 @@
{% extends 'base.html' %}{% block content %}<main class="home"><h1>Страница недоступна</h1><p>{{ message }}</p><a href="/">На главную</a></main>{% endblock %}
+1
View File
@@ -0,0 +1 @@
<footer class="site-footer"><span>Parser Lab · Учебный практикум</span><span>Таблицы / Сообщения / Анкеты</span></footer>
+1
View File
@@ -0,0 +1 @@
<header class="topbar"><a class="brand" href="/"><span class="brand-mark">[ ]</span>Parser Lab</a><span class="top-note">Практикум по работе с данными</span><a class="quiet-link" href="/admin">Кабинет преподавателя ↗</a></header>
+2
View File
@@ -0,0 +1,2 @@
{% extends 'base.html' %}{% block content %}
<main class="home"><section class="home-intro"><div class="eyebrow">PARSER LAB / ПРАКТИКУМ</div><h1>Учебные коллекции данных</h1><p class="lead">Материалы для занятий по веб-парсингу и подготовке данных к машинному обучению.</p><div class="format-list"><span>Таблицы</span><span>Карточки</span><span>Сообщения</span><span>Анкеты</span></div></section><div class="home-guide"><section><h2>Студентам</h2><p>Откройте коллекцию по ссылке из задания. Изучите страницы, соберите записи и сохраните результат для анализа. Регистрация не нужна.</p></section><section><h2>Преподавателю</h2><p>Загрузите CSV, JSON или JSONL, настройте отображение и опубликуйте коллекцию. Ссылки на готовые страницы находятся в кабинете.</p><a class="button" href="/admin">Открыть кабинет →</a></section></div></main>{% endblock %}
+3
View File
@@ -0,0 +1,3 @@
{% extends 'base.html' %}{% block content %}
<main class="login-layout"><section class="login-card"><span class="brand-mark">[ ]</span><h1>Кабинет преподавателя</h1><p class="muted">Загрузка данных и управление публикациями.</p>
{% if error %}<div class="error" role="alert">{{ error }}</div>{% endif %}<form method="post" action="/login"><input type="hidden" name="csrf" value="{{ csrf }}"><label>Логин<input name="username" autocomplete="username" required autofocus></label><label>Пароль<input name="password" type="password" autocomplete="current-password" required></label><button type="submit">Войти в кабинет <span>→</span></button></form><p class="login-foot">Для выполнения задания вход не требуется. Откройте ссылку на коллекцию, которую выдал преподаватель.</p></section></main>{% endblock %}
+1
View File
@@ -0,0 +1 @@
{% extends 'base.html' %}{% block content %}{% include 'collection.html' %}{% if live %}<script src="/static/live.js" defer></script>{% endif %}{% endblock %}
@@ -0,0 +1,9 @@
<div class="lab-records {{ kind }}" data-kind="{{ kind }}">
{% if not items %}<div class="empty">По вашему запросу ничего не найдено.</div>{% elif kind == 'table' and not detail %}
<div class="table-scroll"><table><thead><tr><th class="row-index">№</th>{% for field in items[0].fields %}<th>{{ field.label }}</th>{% endfor %}<th></th></tr></thead><tbody>{% for record in items %}<tr class="record" data-record-id="{{ record.id }}"><td class="row-index">{{ record.id }}</td>{% for field in record.fields %}<td class="field" data-field="{{ field.pointer }}" data-value-state="{{ field.state }}" data-value-type="{{ field.type }}" data-label="{{ field.label }}">{{ field.text }}</td>{% endfor %}<td>{% if base %}<a class="detail-link" href="{{ base }}/records/{{ record.id }}" aria-label="Открыть запись {{ record.id }}">↗</a>{% endif %}</td></tr>{% endfor %}</tbody></table></div>
{% else %}<div class="record-grid {{ 'single' if detail or kind in ['messages','questionnaire'] else '' }}">{% for record in items %}<article class="record record-card {{ kind }}" data-record-id="{{ record.id }}">
<div class="record-top"><span class="eyebrow">{{ 'УЧАСТНИК' if kind == 'questionnaire' else 'ЗАПИСЬ' }} {{ record.id }}</span>{% if base and not detail %}<a class="detail-link" href="{{ base }}/records/{{ record.id }}">Открыть ↗</a>{% endif %}</div>
{% if kind == 'messages' %}<div class="message-author"><span class="avatar">{{ (record.roles.get('author','А') or 'А')[:1] }}</span><div>{% if record.role_fields.get('author') %}{% set f=record.role_fields.author %}<strong class="field" data-role="author" data-field="{{ f.pointer }}" data-label="Автор" data-value-state="{{ f.state }}" data-value-type="{{ f.type }}">{{ f.text }}</strong>{% else %}<strong>Анонимный автор</strong>{% endif %}{% if record.role_fields.get('date') %}{% set f=record.role_fields.date %}<div class="muted field" data-role="date" data-field="{{ f.pointer }}" data-label="Дата" data-value-state="{{ f.state }}" data-value-type="{{ f.type }}">{{ f.text }}</div>{% endif %}</div></div>{% if record.role_fields.get('text') %}{% set f=record.role_fields.text %}<p class="message-text field" data-role="text" data-field="{{ f.pointer }}" data-label="Текст" data-value-state="{{ f.state }}" data-value-type="{{ f.type }}">{{ f.text }}</p>{% endif %}
{% elif record.role_fields.get('title') %}{% set f=record.role_fields.title %}<h2 class="field" data-role="title" data-field="{{ f.pointer }}" data-label="Заголовок" data-value-state="{{ f.state }}" data-value-type="{{ f.type }}">{{ f.text }}</h2>{% endif %}
<dl>{% for field in record.fields %}<div class="field-pair"><dt>{{ field.label }}</dt><dd class="field" data-field="{{ field.pointer }}" data-value-state="{{ field.state }}" data-value-type="{{ field.type }}" data-label="{{ field.label }}">{{ field.text }}</dd></div>{% endfor %}</dl></article>{% endfor %}</div>{% endif %}</div>
+19
View File
@@ -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)
+138
View File
@@ -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
+258
View File
@@ -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()<deadline:
try:
if requests.get(base+'/healthz',timeout=.5).ok: break
except requests.RequestException: pass
if process.poll() is not None: raise RuntimeError((folder/'server.log').read_text('utf-8'))
time.sleep(.1)
else: raise RuntimeError('Server startup timeout')
yield base,folder
finally:
process.terminate()
try: process.wait(timeout=10)
except subprocess.TimeoutExpired: process.kill();process.wait()
@pytest.fixture
def page():
with sync_playwright() as p:
channel=os.environ.get('PLAYWRIGHT_CHANNEL')
browser=p.chromium.launch(headless=True,**({'channel':channel} if channel else {}))
yield browser.new_page(viewport={'width':1440,'height':1000})
browser.close()
def press(page,name,target):
page.get_by_role('button',name=name,exact=True).click()
expect(target).to_be_visible()
def test_teacher_import_and_publish(page,live):
base,folder=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()
page.get_by_role('link',name='Загрузить данные',exact=True).click()
page.locator('input[type=file]').set_input_files({'name':'browser.csv','mimeType':'text/csv','buffer':b'id,answer\n001,7\n002,5'})
page.get_by_role('button',name='Проверить файл',exact=True).click()
expect(page.locator('#import-save')).to_be_enabled()
press(page,'Сохранить и настроить →',page.get_by_text('Включить это представление',exact=True))
page.locator('input[type=checkbox]').first.check()
expect(page.get_by_text('Поля и подписи',exact=True)).to_be_visible()
press(page,'Сохранить черновик',page.get_by_text('Черновик сохранён',exact=True))
with page.expect_popup() as opened:
page.get_by_role('link',name='Предпросмотр ↗',exact=True).click()
expect(opened.value.locator('.record')).to_have_count(2)
opened.value.close()
press(page,'Опубликовать новую версию',page.get_by_text('Версия 1 · Доступна',exact=True))
url=page.get_by_role('link',name='HTML · BeautifulSoup ↗').first.get_attribute('href')
page.goto(base+url)
expect(page.locator('.record')).to_have_count(2)
assert page.locator('.field').all_text_contents()==['001','7','002','5']
@pytest.mark.parametrize('kind',['table','cards','messages','questionnaire'])
def test_views_filters_mobile_and_scrapers(page,live,kind):
base,folder=live
sys.path.insert(0,str(ROOT/'examples/parsers'))
from scrape_html import scrape as html_scrape
from common import extract
expected=html_scrape(f'{base}/html/browser-study/v/1/{kind}')
assert len(expected)==51 and expected[0]['fields'][0]['text']=='Person 001'
assert 'DO-NOT-PUBLISH' not in str(expected)
page.goto(f'{base}/interactive/browser-study/v/1/{kind}')
expect(page.locator('.record')).to_have_count(25)
for count in (50,51):
for attempt in range(3):
page.get_by_role('button',name='Загрузить ещё',exact=True).click()
try: expect(page.locator('.record')).to_have_count(count,timeout=1800);break
except AssertionError:
if attempt==2: raise
assert extract(page.content())==expected
page.get_by_label('Поиск в коллекции',exact=True).fill('WALK')
expect(page.locator('.record')).to_have_count(17)
page.set_viewport_size({'width':390,'height':844})
page.wait_for_function('document.documentElement.scrollWidth <= innerWidth+1')
page.screenshot(path=str(folder/f'{kind}-mobile.png'),full_page=True)
assert 'DO-NOT-PUBLISH' not in page.content()
def test_playwright_example(live):
base,_=live
sys.path.insert(0,str(ROOT/'examples/parsers'))
from scrape_interactive import scrape
records=scrape(f'{base}/interactive/browser-study/v/1/table',os.environ.get('PLAYWRIGHT_CHANNEL'))
assert len(records)==51 and [r['id'] for r in records]==[str(n) for n in range(1,52)]
def test_live_editor_and_mobile_catalog(page,live):
base,folder=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.locator('.catalog-title').filter(has_text='Browser study')).to_be_visible()
page.screenshot(path=str(folder/'catalog-desktop.png'),full_page=True)
page.get_by_role('link',name='Открыть Browser study',exact=True).click()
preview=page.locator('.editor-preview')
expect(preview.locator('.record')).to_have_count(5)
label=page.get_by_label('Подпись поля',exact=True).first
label.fill('Имя участника')
label.press('Tab')
expect(preview.locator('th').filter(has_text='Имя участника')).to_be_visible()
assert preview.evaluate('(e)=>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()
+50
View File
@@ -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
+35
View File
@@ -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'
+16
View File
@@ -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?'
+67
View File
@@ -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()
+99
View File
@@ -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':'<script>alert(1)</script>','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()=='<script>alert(1)</script>'
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'
Generated
+547
View File
@@ -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" },
]