feat(taskflow): add core task API, storage persistence, csv export, stats page, and test coverage

This commit is contained in:
Alexander Kalinovsky
2026-04-01 17:56:03 +03:00
commit 19d659df6b
31 changed files with 4197 additions and 0 deletions

23
.gitignore vendored Normal file
View File

@@ -0,0 +1,23 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
.pytest_cache/
# Virtual environments
.venv
# Environment variables
.env
# AI artifacts
ai_artifacts/
# Data
data/
# Miscellaneous
.DS_Store

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.14

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1 \
PATH="/app/.venv/bin:${PATH}"
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
&& ln -s /root/.local/bin/uv /usr/local/bin/uv
COPY pyproject.toml uv.lock* ./
RUN uv sync --frozen --no-dev
COPY app ./app
COPY templates ./templates
COPY data ./data
RUN mkdir -p /app/data
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

432
PROMTS.md Normal file
View File

@@ -0,0 +1,432 @@
# Prompts Log
## Prompt 1: Project Setup
Стэк - Python 3.14, Pydantic v2, FastAPI 0.135, pytest, Docker для локального рантайма
Описание проекта - локальный трекер задач "Backlog -> In progress -> Done" с чистым API, валидацией, подсчетом метрик workflow и HTML-страницей /stats. Сохранение состояния приперезапуске сервиса и контейнера в data/tasks.json
Пользовательский сценарии
1. Создать задачу.
2. Просмотреть список с фильтрами по статусу/поиску.
3. Перевести задачу в inprogress, затем в done.
4. Удалить задачу.
5. Посмотреть статистику.
6. Экспортировать данные в csv.
Минимальные тесты:
1. CRUD и фильтры - все 2xx/4xx по спецификации.
2. Переходы и ошибки статусов задач 409 invalid_transaction
3. Метрики: корректные avg_cycle/lead.
4. Экспорт CSV: text/csv, корректный заголовок.
5. Сохранение данных между сессиями.
6. Производительность: обработка типичных запросов ≤ 100 мс.
Структура репозитория app/, data/, tests/
Цель - запуск сервиса в докере одной командой, успешное прохождение всех тестов
Архитектура:
- транспортный слой,
- доменный слой - бизнес-логика
- слой хранения
Сущности:
Task: {id: uuid, title: str(100), created_at: datetime, started_at: datetime, done_at: datetime}
Эндпоинты:
GET /api/tasks с пагинацией (default=100) и опциональными фильтрами
GET /api/tasks/{id}
POST /api/tasks
PATCH /api/tasks/{id}
DELETE /api/tasks/{id}
POST /api/tasks/{id}/start - идемпотентность (повторный старт не меняет записанную дату)
POST /api/tasks/{id}/done - целостность (соблюдение потока backlog -> inprogress -> done)
GET /api/tasks/export - возвращает tasks.csv с защитой от csv-инъекций
GET /stats - HTML-страница со статистикой (вверху блок с информацией о выбранной задаче: title, start dt, done dt, Cycle time, внизу канбан доска с плашками задач)
Требования и соглашения:
- дата время серверные UTC ISO-8601
- атомарная запись состояния (временный файл + rename), при повреждении данных бэкап и сброс состояния, чтобы не ломался сервис
- единый формат ошибок invalid_* (в т.ч. invalid_id)
- валидация входных данных
Сгенерируй README.md проекта в формате unified diff (новый файл) содержащий грамотный инженерный каркас на английском языке и договоренности по предоставленным данным.
## Prompt 2: main.py
Сгенерируй diff для main.py (только создание приложения), пустых __init__.py для всех пакетов проекта, эндпоинт health для пинга с выводом серверного времени в отдельном модуле
## Prompt 3: health testplan
спланируй тесты для эндпоинта health, выведи таблицу с положительными и отрицательными кейсами и ожидаемыми результатами (пара положительных и один отрицательный)
## Prompt 4: health tests
сгенерируй код предложенных тестов в формате diff
## Prompt 5: DTO
Сгенерируй код DTO для эндпоинтов task. Выведи diff.
## Prompt 6: storage layer
Сгенерируй код слоя storage. Должна учитываться версия формата данных. Выведи только diff
## Prompt 7: testplan for storage layer
спланируй тесты для слоя storage. по 1-2 позитивных и один негативный на публичные контракты, также учти сценарии:
- приложение завершилось между записью и rename — данные не повреждены;
- файл tasks.json повреждён — сервис стартует с пустым состоянием и сохраняет бэкап.
выведи таблицу с кейсами и ожидаемым выводом
## Prompt 8: tests for storage layer
Сгенерируй код тестов по предложенному плану. Не фиксируй дату для тестов, используй текущую. пришли diff
## Prompt 9: domain layer
Сгенерируй код функций start_task(task), complete_task(task) в доменном слое. пришли diff
## Prompt 10: testplan for domain layer
спланируй тесты для функций доменного слоя, 1-2 позитивных и 1 негативный на каждую функцию. выведи в виде таблицы с кейсами и ожидаемым выводом
## Prompt 11: tests for domain layer
напиши код тестов по предложенному плану. пришли дифф
## Prompt 12: tasks endpoints
Напиши код для эндпоинтов tasks (crud функции и методы start и done). Пришли дифф
## Prompt 13: testplan for tasks endpoints
спланируй тесты для эндпоинта tasks по 1-2 позитивному и 1 негативный на каждый метод. выведи в виде таблицы
## Prompt 14: tests for tasks endpoints
напиши код тестов по предложенному плану. верни дифф
## Prompt 15: fix test fails
(task-flow) kak@BigBrother task_flow % pytest -v tests/test_api_tasks.py
========================================================= test session starts ==========================================================
platform darwin -- Python 3.14.2, pytest-9.0.2, pluggy-1.6.0 -- /Users/kak/Documents/Projects/vibe_coding_learning/task_flow/.venv/bin/python3
cachedir: .pytest_cache
rootdir: /Users/kak/Documents/Projects/vibe_coding_learning/task_flow
configfile: pyproject.toml
plugins: anyio-4.13.0
collected 0 items / 1 error
================================================================ ERRORS ================================================================
_______________________________________________ ERROR collecting tests/test_api_tasks.py _______________________________________________
tests/test_api_tasks.py:8: in <module>
from app.api.tasks import get_task_repository
app/api/__init__.py:2: in <module>
from app.api.tasks import router as tasks_router
app/api/tasks.py:205: in <module>
@router.delete(
.venv/lib/python3.14/site-packages/fastapi/routing.py:1450: in decorator
self.add_api_route(
.venv/lib/python3.14/site-packages/fastapi/routing.py:1386: in add_api_route
route = route_class(
.venv/lib/python3.14/site-packages/fastapi/routing.py:905: in __init__
assert is_body_allowed_for_status_code(status_code), (
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E AssertionError: Status code 204 must not have a response body
======================================================= short test summary info ========================================================
ERROR tests/test_api_tasks.py - AssertionError: Status code 204 must not have a response body
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
=========================================================== 1 error in 0.22s ===========================================================
исправь, верни дифф
## Prompt 16: fix test fails 2
(task-flow) kak@BigBrother task_flow % pytest -v tests/test_api_tasks.py
========================================================= test session starts ==========================================================
platform darwin -- Python 3.14.2, pytest-9.0.2, pluggy-1.6.0 -- /Users/kak/Documents/Projects/vibe_coding_learning/task_flow/.venv/bin/python3
cachedir: .pytest_cache
rootdir: /Users/kak/Documents/Projects/vibe_coding_learning/task_flow
configfile: pyproject.toml
plugins: anyio-4.13.0
collected 0 items / 1 error
================================================================ ERRORS ================================================================
_______________________________________________ ERROR collecting tests/test_api_tasks.py _______________________________________________
tests/test_api_tasks.py:8: in <module>
from app.api.tasks import get_task_repository
app/api/__init__.py:2: in <module>
from app.api.tasks import router as tasks_router
app/api/tasks.py:205: in <module>
@router.delete(
.venv/lib/python3.14/site-packages/fastapi/routing.py:1450: in decorator
self.add_api_route(
.venv/lib/python3.14/site-packages/fastapi/routing.py:1386: in add_api_route
route = route_class(
.venv/lib/python3.14/site-packages/fastapi/routing.py:905: in __init__
assert is_body_allowed_for_status_code(status_code), (
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
E AssertionError: Status code 204 must not have a response body
======================================================= short test summary info ========================================================
ERROR tests/test_api_tasks.py - AssertionError: Status code 204 must not have a response body
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
=========================================================== 1 error in 0.24s ===========================================================
патч не помог, исправь. верни дифф
## Prompt 17: fix test fails 3
(task-flow) kak@BigBrother task_flow % pytest -v tests/test_api_tasks.py
========================================================= test session starts ==========================================================
platform darwin -- Python 3.14.2, pytest-9.0.2, pluggy-1.6.0 -- /Users/kak/Documents/Projects/vibe_coding_learning/task_flow/.venv/bin/python3
cachedir: .pytest_cache
rootdir: /Users/kak/Documents/Projects/vibe_coding_learning/task_flow
configfile: pyproject.toml
plugins: anyio-4.13.0
collected 27 items
tests/test_api_tasks.py::test_list_tasks_returns_empty_list_by_default PASSED [ 3%]
tests/test_api_tasks.py::test_list_tasks_filters_by_status_and_search PASSED [ 7%]
tests/test_api_tasks.py::test_list_tasks_rejects_invalid_status_filter PASSED [ 11%]
tests/test_api_tasks.py::test_get_task_returns_existing_task PASSED [ 14%]
tests/test_api_tasks.py::test_get_task_returns_done_status_for_completed_task PASSED [ 18%]
tests/test_api_tasks.py::test_get_task_returns_invalid_id_for_bad_uuid PASSED [ 22%]
tests/test_api_tasks.py::test_create_task_creates_new_backlog_task PASSED [ 25%]
tests/test_api_tasks.py::test_create_task_trims_title PASSED [ 29%]
tests/test_api_tasks.py::test_create_task_rejects_invalid_payload PASSED [ 33%]
tests/test_api_tasks.py::test_update_task_updates_title PASSED [ 37%]
tests/test_api_tasks.py::test_update_task_keeps_existing_timestamps FAILED [ 40%]
tests/test_api_tasks.py::test_update_task_returns_not_found_for_missing_task PASSED [ 44%]
tests/test_api_tasks.py::test_delete_task_removes_existing_task PASSED [ 48%]
tests/test_api_tasks.py::test_delete_task_only_removes_target_task PASSED [ 51%]
tests/test_api_tasks.py::test_delete_task_returns_invalid_id_for_bad_uuid PASSED [ 55%]
tests/test_api_tasks.py::test_start_task_transitions_backlog_to_in_progress PASSED [ 59%]
tests/test_api_tasks.py::test_start_task_is_idempotent FAILED [ 62%]
tests/test_api_tasks.py::test_start_task_rejects_completed_task PASSED [ 66%]
tests/test_api_tasks.py::test_done_task_transitions_in_progress_to_done FAILED [ 70%]
tests/test_api_tasks.py::test_done_task_is_idempotent_for_completed_task FAILED [ 74%]
tests/test_api_tasks.py::test_done_task_rejects_backlog_task PASSED [ 77%]
tests/test_api_tasks.py::test_list_tasks_rejects_invalid_limit PASSED [ 81%]
tests/test_api_tasks.py::test_get_task_returns_not_found_for_missing_uuid PASSED [ 85%]
tests/test_api_tasks.py::test_patch_task_rejects_invalid_payload PASSED [ 88%]
tests/test_api_tasks.py::test_delete_task_returns_not_found_for_missing_uuid PASSED [ 92%]
tests/test_api_tasks.py::test_start_task_returns_not_found_for_missing_uuid PASSED [ 96%]
tests/test_api_tasks.py::test_done_task_returns_not_found_for_missing_uuid PASSED [100%]
=============================================================== FAILURES ===============================================================
______________________________________________ test_update_task_keeps_existing_timestamps ______________________________________________
tmp_path = PosixPath('/private/var/folders/6x/qy6dwbb95ng55w0rd2sqpf3w0000gn/T/pytest-of-kak/pytest-3/test_update_task_keeps_existin0')
def test_update_task_keeps_existing_timestamps(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Workflow", started=True, done=True)
repo.create_task(task)
response = client.patch(
f"/api/tasks/{task.id}",
json={"title": "Workflow updated"},
)
assert response.status_code == 200
data = response.json()
assert data["title"] == "Workflow updated"
> assert data["started_at"] == task.started_at.isoformat()
E AssertionError: assert '2026-03-29T15:47:28.534306Z' == '2026-03-29T1....534306+00:00'
E
E - 2026-03-29T15:47:28.534306+00:00
E ? ^^^^^^
E + 2026-03-29T15:47:28.534306Z
E ? ^
tests/test_api_tasks.py:194: AssertionError
____________________________________________________ test_start_task_is_idempotent _____________________________________________________
tmp_path = PosixPath('/private/var/folders/6x/qy6dwbb95ng55w0rd2sqpf3w0000gn/T/pytest-of-kak/pytest-3/test_start_task_is_idempotent0')
def test_start_task_is_idempotent(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Already started", started=True, done=False)
repo.create_task(task)
response = client.post(f"/api/tasks/{task.id}/start")
assert response.status_code == 200
data = response.json()
assert data["status"] == "in_progress"
> assert data["started_at"] == task.started_at.isoformat()
E AssertionError: assert '2026-03-29T15:47:28.560807Z' == '2026-03-29T1....560807+00:00'
E
E - 2026-03-29T15:47:28.560807+00:00
E ? ^^^^^^
E + 2026-03-29T15:47:28.560807Z
E ? ^
tests/test_api_tasks.py:271: AssertionError
____________________________________________ test_done_task_transitions_in_progress_to_done ____________________________________________
tmp_path = PosixPath('/private/var/folders/6x/qy6dwbb95ng55w0rd2sqpf3w0000gn/T/pytest-of-kak/pytest-3/test_done_task_transitions_in_0')
def test_done_task_transitions_in_progress_to_done(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Complete me", started=True, done=False)
repo.create_task(task)
before_call = datetime.now(UTC)
response = client.post(f"/api/tasks/{task.id}/done")
after_call = datetime.now(UTC)
assert response.status_code == 200
data = response.json()
assert data["status"] == "done"
> assert data["started_at"] == task.started_at.isoformat()
E AssertionError: assert '2026-03-29T15:47:28.568179Z' == '2026-03-29T1....568179+00:00'
E
E - 2026-03-29T15:47:28.568179+00:00
E ? ^^^^^^
E + 2026-03-29T15:47:28.568179Z
E ? ^
tests/test_api_tasks.py:297: AssertionError
___________________________________________ test_done_task_is_idempotent_for_completed_task ____________________________________________
tmp_path = PosixPath('/private/var/folders/6x/qy6dwbb95ng55w0rd2sqpf3w0000gn/T/pytest-of-kak/pytest-3/test_done_task_is_idempotent_f0')
def test_done_task_is_idempotent_for_completed_task(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Already done", started=True, done=True)
repo.create_task(task)
response = client.post(f"/api/tasks/{task.id}/done")
assert response.status_code == 200
data = response.json()
assert data["status"] == "done"
> assert data["done_at"] == task.done_at.isoformat()
E AssertionError: assert '2026-03-29T15:56:28.572797Z' == '2026-03-29T1....572797+00:00'
E
E - 2026-03-29T15:56:28.572797+00:00
E ? ^^^^^^
E + 2026-03-29T15:56:28.572797Z
E ? ^
tests/test_api_tasks.py:313: AssertionError
======================================================= short test summary info ========================================================
FAILED tests/test_api_tasks.py::test_update_task_keeps_existing_timestamps - AssertionError: assert '2026-03-29T15:47:28.534306Z' == '2026-03-29T1....534306+00:00'
FAILED tests/test_api_tasks.py::test_start_task_is_idempotent - AssertionError: assert '2026-03-29T15:47:28.560807Z' == '2026-03-29T1....560807+00:00'
FAILED tests/test_api_tasks.py::test_done_task_transitions_in_progress_to_done - AssertionError: assert '2026-03-29T15:47:28.568179Z' == '2026-03-29T1....568179+00:00'
FAILED tests/test_api_tasks.py::test_done_task_is_idempotent_for_completed_task - AssertionError: assert '2026-03-29T15:56:28.572797Z' == '2026-03-29T1....572797+00:00'
===================================================== 4 failed, 23 passed in 0.22s =====================================================
некоторые тесты упали, верни дифф
## Prompt 18: export csv endpoint
сгенерируй код экспорта csv, верни diff
## Prompt 19: testplan for csv export
спланируй тесты для экспорта csv, учти корректность MIME. верни в виде таблицы
## Prompt 20: tests for csv export
напиши код предложенных тестов, верни дифф
## Prompt 21: stats page
напиши код возврата HTML-страницы /stats, шаблон размести в /templates. реализуй динамическое обновление блока с информацией о выбранной задаче по клику на плашках задач в доске. верни дифф
## Prompt 22: testplan for stats page
спланируй тесты для /stats. выведи в виде таблицы
## Prompt 23: tests for stats page
напиши код предложенных тестов. верни дифф
## Prompt 24: fix tests fails
(task-flow) kak@BigBrother task_flow % pytest -v tests/test_api_stats.py
========================================================= test session starts ==========================================================
platform darwin -- Python 3.14.2, pytest-9.0.2, pluggy-1.6.0 -- /Users/kak/Documents/Projects/vibe_coding_learning/task_flow/.venv/bin/python3
cachedir: .pytest_cache
rootdir: /Users/kak/Documents/Projects/vibe_coding_learning/task_flow
configfile: pyproject.toml
plugins: anyio-4.13.0
collected 12 items
tests/test_api_stats.py::test_stats_returns_html_page_for_empty_board PASSED [ 8%]
tests/test_api_stats.py::test_stats_renders_tasks_in_all_kanban_columns PASSED [ 16%]
tests/test_api_stats.py::test_stats_preselects_first_task_in_details_block FAILED [ 25%]
tests/test_api_stats.py::test_stats_shows_placeholders_for_missing_dates PASSED [ 33%]
tests/test_api_stats.py::test_stats_renders_cycle_time_for_completed_task PASSED [ 41%]
tests/test_api_stats.py::test_stats_embeds_task_payload_for_client_side_switching PASSED [ 50%]
tests/test_api_stats.py::test_stats_includes_js_hooks_for_dynamic_selected_task_update PASSED [ 58%]
tests/test_api_stats.py::test_stats_handles_utf8_titles PASSED [ 66%]
tests/test_api_stats.py::test_stats_escapes_html_in_task_title PASSED [ 75%]
tests/test_api_stats.py::test_stats_rejects_unsupported_method PASSED [ 83%]
tests/test_api_stats.py::test_stats_still_works_after_recovery_from_corrupted_file PASSED [ 91%]
tests/test_api_stats.py::test_stats_renders_created_started_and_done_values_for_completed_task FAILED [100%]
=============================================================== FAILURES ===============================================================
__________________________________________ test_stats_preselects_first_task_in_details_block ___________________________________________
tmp_path = PosixPath('/private/var/folders/6x/qy6dwbb95ng55w0rd2sqpf3w0000gn/T/pytest-of-kak/pytest-7/test_stats_preselects_first_ta0')
def test_stats_preselects_first_task_in_details_block(tmp_path):
client, repo = make_client(tmp_path)
first = make_task(title="First task", started=False, done=False)
second = make_task(title="Second task", started=True, done=False)
repo.create_task(first)
repo.create_task(second)
response = client.get("/stats")
assert response.status_code == 200
assert 'id="selected-title"' in response.text
assert 'id="selected-created-at"' in response.text
assert "First task" in response.text
> assert first.created_at.isoformat().replace("+00:00", "Z") in response.text
E assert '2026-03-29T16:05:30.901908Z' in '<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n <title>TaskFlow Stats</title>\n <style>\n :root {\n color-scheme: light;\n }\n\n body {\n margin: 0;\n font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;\n background: #f5f7fb;\n color: #1f2937;\n }\n\n .page {\n max-width: 1200px;\n margin: 0 auto;\n padding: 24px;\n }\n\n .title {\n margin: 0 0 20px 0;\n font-size: 28px;\n font-weight: 700;\n }\n\n .details-card {\n background: #ffffff;\n border-radius: 16px;\n padding: 20px;\n box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);\n margin-bottom: 24px;\n }\n\n .details-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 12px 20px;\n }\n\n .details-item {\n background: #f8fafc;\n border-radius: 12px;\n pa...StartedAt = document.getElementById("selected-started-at");\n const selectedDoneAt = document.getElementById("selected-done-at");\n const selectedCycleTime = document.getElementById("selected-cycle-time");\n const selectedCreatedAt = document.getElementById("selected-created-at");\n\n function renderTask(task) {\n selectedTitle.textContent = task.title || "No task selected";\n selectedStatus.textContent = task.status || "—";\n selectedStartedAt.textContent = task.started_at || "—";\n selectedDoneAt.textContent = task.done_at || "—";\n selectedCycleTime.textContent = task.cycle_time || "—";\n selectedCreatedAt.textContent = task.created_at || "—";\n }\n\n cards.forEach((card, index) => {\n if (index === 0) {\n card.classList.add("active");\n }\n\n card.addEventListener("click", () => {\n cards.forEach((item) => item.classList.remove("active"));\n card.classList.add("active");\n renderTask(JSON.parse(card.dataset.task));\n });\n });\n </script>\n</body>\n</html>\n'
E + where '2026-03-29T16:05:30.901908Z' = <built-in method replace of str object at 0x10b29d520>('+00:00', 'Z')
E + where <built-in method replace of str object at 0x10b29d520> = '2026-03-29T16:05:30.901908+00:00'.replace
E + where '2026-03-29T16:05:30.901908+00:00' = <built-in method isoformat of datetime.datetime object at 0x10b069fe0>()
E + where <built-in method isoformat of datetime.datetime object at 0x10b069fe0> = datetime.datetime(2026, 3, 29, 16, 5, 30, 901908, tzinfo=datetime.timezone.utc).isoformat
E + where datetime.datetime(2026, 3, 29, 16, 5, 30, 901908, tzinfo=datetime.timezone.utc) = StoredTask(id=UUID('39e369b2-bcc3-4f33-9a70-d54ce0d916d3'), title='First task', created_at=datetime.datetime(2026, 3, 29, 16, 5, 30, 901908, tzinfo=datetime.timezone.utc), started_at=None, done_at=None).created_at
E + and '<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n <title>TaskFlow Stats</title>\n <style>\n :root {\n color-scheme: light;\n }\n\n body {\n margin: 0;\n font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;\n background: #f5f7fb;\n color: #1f2937;\n }\n\n .page {\n max-width: 1200px;\n margin: 0 auto;\n padding: 24px;\n }\n\n .title {\n margin: 0 0 20px 0;\n font-size: 28px;\n font-weight: 700;\n }\n\n .details-card {\n background: #ffffff;\n border-radius: 16px;\n padding: 20px;\n box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);\n margin-bottom: 24px;\n }\n\n .details-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 12px 20px;\n }\n\n .details-item {\n background: #f8fafc;\n border-radius: 12px;\n pa...StartedAt = document.getElementById("selected-started-at");\n const selectedDoneAt = document.getElementById("selected-done-at");\n const selectedCycleTime = document.getElementById("selected-cycle-time");\n const selectedCreatedAt = document.getElementById("selected-created-at");\n\n function renderTask(task) {\n selectedTitle.textContent = task.title || "No task selected";\n selectedStatus.textContent = task.status || "—";\n selectedStartedAt.textContent = task.started_at || "—";\n selectedDoneAt.textContent = task.done_at || "—";\n selectedCycleTime.textContent = task.cycle_time || "—";\n selectedCreatedAt.textContent = task.created_at || "—";\n }\n\n cards.forEach((card, index) => {\n if (index === 0) {\n card.classList.add("active");\n }\n\n card.addEventListener("click", () => {\n cards.forEach((item) => item.classList.remove("active"));\n card.classList.add("active");\n renderTask(JSON.parse(card.dataset.task));\n });\n });\n </script>\n</body>\n</html>\n' = <Response [200 OK]>.text
tests/test_api_stats.py:94: AssertionError
________________________________ test_stats_renders_created_started_and_done_values_for_completed_task _________________________________
tmp_path = PosixPath('/private/var/folders/6x/qy6dwbb95ng55w0rd2sqpf3w0000gn/T/pytest-of-kak/pytest-7/test_stats_renders_created_sta0')
def test_stats_renders_created_started_and_done_values_for_completed_task(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Detailed task", started=True, done=True)
repo.create_task(task)
response = client.get("/stats")
assert response.status_code == 200
> assert task.created_at.isoformat().replace("+00:00", "Z") in response.text
E assert '2026-03-29T16:05:30.934052Z' in '<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n <title>TaskFlow Stats</title>\n <style>\n :root {\n color-scheme: light;\n }\n\n body {\n margin: 0;\n font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;\n background: #f5f7fb;\n color: #1f2937;\n }\n\n .page {\n max-width: 1200px;\n margin: 0 auto;\n padding: 24px;\n }\n\n .title {\n margin: 0 0 20px 0;\n font-size: 28px;\n font-weight: 700;\n }\n\n .details-card {\n background: #ffffff;\n border-radius: 16px;\n padding: 20px;\n box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);\n margin-bottom: 24px;\n }\n\n .details-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 12px 20px;\n }\n\n .details-item {\n background: #f8fafc;\n border-radius: 12px;\n pa...StartedAt = document.getElementById("selected-started-at");\n const selectedDoneAt = document.getElementById("selected-done-at");\n const selectedCycleTime = document.getElementById("selected-cycle-time");\n const selectedCreatedAt = document.getElementById("selected-created-at");\n\n function renderTask(task) {\n selectedTitle.textContent = task.title || "No task selected";\n selectedStatus.textContent = task.status || "—";\n selectedStartedAt.textContent = task.started_at || "—";\n selectedDoneAt.textContent = task.done_at || "—";\n selectedCycleTime.textContent = task.cycle_time || "—";\n selectedCreatedAt.textContent = task.created_at || "—";\n }\n\n cards.forEach((card, index) => {\n if (index === 0) {\n card.classList.add("active");\n }\n\n card.addEventListener("click", () => {\n cards.forEach((item) => item.classList.remove("active"));\n card.classList.add("active");\n renderTask(JSON.parse(card.dataset.task));\n });\n });\n </script>\n</body>\n</html>\n'
E + where '2026-03-29T16:05:30.934052Z' = <built-in method replace of str object at 0x10b304c60>('+00:00', 'Z')
E + where <built-in method replace of str object at 0x10b304c60> = '2026-03-29T16:05:30.934052+00:00'.replace
E + where '2026-03-29T16:05:30.934052+00:00' = <built-in method isoformat of datetime.datetime object at 0x10b075080>()
E + where <built-in method isoformat of datetime.datetime object at 0x10b075080> = datetime.datetime(2026, 3, 29, 16, 5, 30, 934052, tzinfo=datetime.timezone.utc).isoformat
E + where datetime.datetime(2026, 3, 29, 16, 5, 30, 934052, tzinfo=datetime.timezone.utc) = StoredTask(id=UUID('768d359e-1625-4735-b885-aa4ce00cb023'), title='Detailed task', created_at=datetime.datetime(2026, 3, 29, 16, 5, 30, 934052, tzinfo=datetime.timezone.utc), started_at=datetime.datetime(2026, 3, 29, 16, 55, 30, 934052, tzinfo=datetime.timezone.utc), done_at=datetime.datetime(2026, 3, 29, 17, 4, 30, 934052, tzinfo=datetime.timezone.utc)).created_at
E + and '<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8">\n <meta name="viewport" content="width=device-width, initial-scale=1.0">\n <title>TaskFlow Stats</title>\n <style>\n :root {\n color-scheme: light;\n }\n\n body {\n margin: 0;\n font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;\n background: #f5f7fb;\n color: #1f2937;\n }\n\n .page {\n max-width: 1200px;\n margin: 0 auto;\n padding: 24px;\n }\n\n .title {\n margin: 0 0 20px 0;\n font-size: 28px;\n font-weight: 700;\n }\n\n .details-card {\n background: #ffffff;\n border-radius: 16px;\n padding: 20px;\n box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);\n margin-bottom: 24px;\n }\n\n .details-grid {\n display: grid;\n grid-template-columns: repeat(2, minmax(0, 1fr));\n gap: 12px 20px;\n }\n\n .details-item {\n background: #f8fafc;\n border-radius: 12px;\n pa...StartedAt = document.getElementById("selected-started-at");\n const selectedDoneAt = document.getElementById("selected-done-at");\n const selectedCycleTime = document.getElementById("selected-cycle-time");\n const selectedCreatedAt = document.getElementById("selected-created-at");\n\n function renderTask(task) {\n selectedTitle.textContent = task.title || "No task selected";\n selectedStatus.textContent = task.status || "—";\n selectedStartedAt.textContent = task.started_at || "—";\n selectedDoneAt.textContent = task.done_at || "—";\n selectedCycleTime.textContent = task.cycle_time || "—";\n selectedCreatedAt.textContent = task.created_at || "—";\n }\n\n cards.forEach((card, index) => {\n if (index === 0) {\n card.classList.add("active");\n }\n\n card.addEventListener("click", () => {\n cards.forEach((item) => item.classList.remove("active"));\n card.classList.add("active");\n renderTask(JSON.parse(card.dataset.task));\n });\n });\n </script>\n</body>\n</html>\n' = <Response [200 OK]>.text
tests/test_api_stats.py:213: AssertionError
======================================================= short test summary info ========================================================
FAILED tests/test_api_stats.py::test_stats_preselects_first_task_in_details_block - assert '2026-03-29T16:05:30.901908Z' in '<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8">\n <meta name="vie...
FAILED tests/test_api_stats.py::test_stats_renders_created_started_and_done_values_for_completed_task - assert '2026-03-29T16:05:30.934052Z' in '<!DOCTYPE html>\n<html lang="en">\n<head>\n <meta charset="UTF-8">\n <meta name="vie...
===================================================== 2 failed, 10 passed in 1.66s =====================================================
некоторые тесты падают, исправь и верни дифф
## Prompt 25: dockerfile
напиши dockerfile для сборки и запуска образа с использованием uv, верни diff

314
README.md Normal file
View File

@@ -0,0 +1,314 @@
# TaskFlow (Local Task Tracker)
Minimalistic local task tracker implementing a simple workflow:
```
Backlog → In Progress → Done
```
The service exposes a clean HTTP API, provides workflow metrics, persists state to disk, and includes a lightweight HTML dashboard.
---
## Tech Stack
- Python 3.14
- FastAPI 0.135
- Pydantic v2
- Pytest
- Docker (local runtime)
---
## Features
- CRUD operations for tasks
- Workflow transitions with validation:
- backlog → in_progress → done
- Idempotent `start` operation
- Metrics:
- Lead Time
- Cycle Time
- CSV export with injection protection
- Persistent storage (`data/tasks.json`)
- HTML dashboard (`/stats`)
- Atomic file writes with corruption recovery
---
## Project Structure
```
.
├── app/ # Application code
│ ├── api/ # Transport layer (FastAPI routers)
│ ├── domain/ # Business logic
│ └── storage/# Persistence layer
├── data/ # Persistent storage (tasks.json)
├── tests/ # Test suite
└── Dockerfile
```
---
## Architecture
The project follows a layered architecture:
### 1. Transport Layer
- FastAPI routers
- Request/response validation (Pydantic)
- HTTP error mapping
### 2. Domain Layer
- Business rules
- Workflow transitions
- Metrics calculation
### 3. Storage Layer
- File-based persistence
- Atomic writes (tmp + rename)
- Corruption recovery (backup + reset)
---
## Domain Model
### Task
```json
{
"id": "uuid",
"title": "string (max 100)",
"created_at": "datetime (UTC ISO-8601)",
"started_at": "datetime | null",
"done_at": "datetime | null"
}
```
### Status Derivation
Status is not stored explicitly:
- backlog → `started_at == null`
- in_progress → `started_at != null && done_at == null`
- done → `done_at != null`
---
## API Endpoints
### Tasks
#### List tasks
```
GET /api/tasks
```
Query params:
- `limit` (default: 100)
- `status` (optional: backlog | in_progress | done)
- `search` (optional substring match)
---
#### Get task by ID
```
GET /api/tasks/{id}
```
---
#### Create task
```
POST /api/tasks
```
---
#### Update task
```
PATCH /api/tasks/{id}
```
---
#### Delete task
```
DELETE /api/tasks/{id}
```
---
### Workflow Actions
#### Start task
```
POST /api/tasks/{id}/start
```
Rules:
- Idempotent (multiple calls do not change `started_at`)
- Valid only if task is in backlog
---
#### Complete task
```
POST /api/tasks/{id}/done
```
Rules:
- Allowed only from `in_progress`
- Enforces workflow integrity
---
### Export
```
GET /api/tasks/export
```
- Returns `text/csv`
- Includes header row
- Protects against CSV injection (`=`, `+`, `-`, `@`)
---
### Stats
```
GET /stats
```
HTML page:
- Top block:
- Selected task details:
- Title
- Start datetime
- Done datetime
- Cycle time
- Bottom:
- Kanban board:
- Backlog
- In Progress
- Done
---
## Error Format
All errors follow unified format:
```
{
"error": "invalid_*",
"message": "human readable description"
}
```
Examples:
- `invalid_id`
- `invalid_payload`
- `invalid_transition`
- `invalid_transaction`
HTTP codes:
- 2xx — success
- 4xx — client errors (validation, transitions)
---
## Data Persistence
- File: `data/tasks.json`
- Writes are atomic:
1. Write to temp file
2. Rename
### Corruption Handling
If JSON is invalid:
- Backup corrupted file
- Reset to empty state
- Service continues running
---
## Metrics
- **Lead Time** = `done_at - created_at`
- **Cycle Time** = `done_at - started_at`
Average values are computed across completed tasks.
---
## Testing Requirements
Test suite must cover:
1. CRUD operations and filters
2. Workflow transitions:
- valid transitions → 2xx
- invalid → `409 invalid_transaction`
3. Metrics correctness
4. CSV export:
- `text/csv`
- correct header
5. Persistence between restarts
6. Performance:
- typical requests ≤ 100 ms
---
## Running with Docker
### Build and run
```
docker build -t taskflow .
docker run -p 8000:8000 -v $(pwd)/data:/app/data taskflow
```
### One-command run (recommended)
```
docker compose up --build
```
---
## Conventions
- All timestamps are UTC (ISO-8601)
- Validation via Pydantic v2
- Strict API contracts
- No implicit state mutations
- Idempotent operations where required
---
## Goal
The project is considered complete when:
- The service runs in Docker with a single command
- All tests pass
- State persists across restarts
- API behaves according to specification
---

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@

6
app/api/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
from app.api.health import router as health_router
from app.api.stats import router as stats_router
from app.api.tasks import router as tasks_router
__all__ = ["health_router", "stats_router", "tasks_router"]

22
app/api/dto/__init__.py Normal file
View File

@@ -0,0 +1,22 @@
from app.api.dto.tasks import (
ErrorDTO,
TaskCreateDTO,
TaskDTO,
TaskExportRowDTO,
TaskListDTO,
TaskListItemDTO,
TaskQueryDTO,
TaskUpdateDTO,
)
__all__ = [
"ErrorDTO",
"TaskCreateDTO",
"TaskDTO",
"TaskExportRowDTO",
"TaskListDTO",
"TaskListItemDTO",
"TaskQueryDTO",
"TaskUpdateDTO",
]

142
app/api/dto/tasks.py Normal file
View File

@@ -0,0 +1,142 @@
from __future__ import annotations
from datetime import datetime
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator
TaskStatus = Literal["backlog", "in_progress", "done"]
class ErrorDTO(BaseModel):
error: str = Field(..., examples=["invalid_id"])
message: str = Field(..., examples=["Task was not found"])
class TaskCreateDTO(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str = Field(
...,
min_length=1,
max_length=100,
description="Task title",
examples=["Implement CSV export"],
)
@field_validator("title")
@classmethod
def validate_title(cls, value: str) -> str:
value = value.strip()
if not value:
raise ValueError("Title must not be empty")
return value
class TaskUpdateDTO(BaseModel):
model_config = ConfigDict(extra="forbid")
title: str | None = Field(
default=None,
min_length=1,
max_length=100,
description="Updated task title",
examples=["Fix workflow metrics"],
)
@field_validator("title")
@classmethod
def validate_title(cls, value: str | None) -> str | None:
if value is None:
return None
value = value.strip()
if not value:
raise ValueError("Title must not be empty")
return value
class TaskDTO(BaseModel):
model_config = ConfigDict(
extra="forbid",
frozen=True,
populate_by_name=True,
)
id: UUID
title: str = Field(..., min_length=1, max_length=100)
created_at: datetime
started_at: datetime | None = None
done_at: datetime | None = None
@computed_field(return_type=str)
@property
def status(self) -> TaskStatus:
if self.done_at is not None:
return "done"
if self.started_at is not None:
return "in_progress"
return "backlog"
class TaskListItemDTO(BaseModel):
model_config = ConfigDict(
extra="forbid",
frozen=True,
populate_by_name=True,
)
id: UUID
title: str = Field(..., min_length=1, max_length=100)
created_at: datetime
started_at: datetime | None = None
done_at: datetime | None = None
@computed_field(return_type=str)
@property
def status(self) -> TaskStatus:
if self.done_at is not None:
return "done"
if self.started_at is not None:
return "in_progress"
return "backlog"
class TaskListDTO(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
items: list[TaskListItemDTO]
total: int = Field(..., ge=0)
limit: int = Field(..., ge=1, le=1000)
offset: int = Field(..., ge=0)
class TaskQueryDTO(BaseModel):
model_config = ConfigDict(extra="forbid")
limit: int = Field(default=100, ge=1, le=1000)
offset: int = Field(default=0, ge=0)
status: TaskStatus | None = Field(default=None)
search: str | None = Field(default=None, max_length=100)
@field_validator("search")
@classmethod
def normalize_search(cls, value: str | None) -> str | None:
if value is None:
return None
value = value.strip()
return value or None
class TaskExportRowDTO(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
id: UUID
title: str
status: TaskStatus
created_at: datetime
started_at: datetime | None = None
done_at: datetime | None = None

18
app/api/health.py Normal file
View File

@@ -0,0 +1,18 @@
from datetime import datetime, UTC
from fastapi import APIRouter
router = APIRouter()
@router.get("/health")
def health() -> dict:
"""
Health check endpoint.
Returns server time in UTC ISO-8601 format.
"""
return {
"status": "ok",
"server_time": datetime.now(UTC).isoformat(),
}

80
app/api/stats.py Normal file
View File

@@ -0,0 +1,80 @@
from __future__ import annotations
from pathlib import Path
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
from fastapi.templating import Jinja2Templates
from app.api.tasks import get_task_repository
from app.storage import JsonFileTaskRepository, StoredTask
router = APIRouter(tags=["stats"])
templates = Jinja2Templates(directory=str(Path(__file__).resolve().parents[2] / "templates"))
def get_task_status(task: StoredTask) -> str:
if task.done_at is not None:
return "done"
if task.started_at is not None:
return "in_progress"
return "backlog"
def format_dt(value: object) -> str:
if value is None:
return ""
return str(value)
def format_duration(task: StoredTask) -> str:
if task.started_at is None or task.done_at is None:
return ""
delta = task.done_at - task.started_at
total_seconds = int(delta.total_seconds())
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
return f"{hours}h {minutes}m {seconds}s"
def serialize_task(task: StoredTask) -> dict[str, str]:
return {
"id": str(task.id),
"title": task.title,
"status": get_task_status(task),
"created_at": task.created_at.isoformat(),
"started_at": task.started_at.isoformat() if task.started_at is not None else "",
"done_at": task.done_at.isoformat() if task.done_at is not None else "",
"cycle_time": format_duration(task),
}
@router.get("/stats", response_class=HTMLResponse)
def stats_page(
request: Request,
repo: JsonFileTaskRepository = Depends(get_task_repository),
) -> HTMLResponse:
tasks = repo.list_tasks()
backlog_tasks = [serialize_task(task) for task in tasks if get_task_status(task) == "backlog"]
in_progress_tasks = [serialize_task(task) for task in tasks if get_task_status(task) == "in_progress"]
done_tasks = [serialize_task(task) for task in tasks if get_task_status(task) == "done"]
selected_task = None
if tasks:
selected_task = serialize_task(tasks[0])
return templates.TemplateResponse(
request=request,
name="stats.html",
context={
"selected_task": selected_task,
"backlog_tasks": backlog_tasks,
"in_progress_tasks": in_progress_tasks,
"done_tasks": done_tasks,
},
)

338
app/api/tasks.py Normal file
View File

@@ -0,0 +1,338 @@
from __future__ import annotations
import csv
from io import StringIO
from datetime import UTC, datetime
from functools import lru_cache
from uuid import UUID, uuid4
from fastapi import APIRouter, Body, Depends, Path, Query, Response, status
from fastapi.responses import JSONResponse
from app.api.dto.tasks import (
ErrorDTO,
TaskCreateDTO,
TaskDTO,
TaskListDTO,
TaskListItemDTO,
TaskQueryDTO,
TaskUpdateDTO,
)
from app.domain import InvalidTransitionError, complete_task, start_task
from app.storage import JsonFileTaskRepository, StoredTask, create_task_repository
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
@lru_cache(maxsize=1)
def get_task_repository() -> JsonFileTaskRepository:
return create_task_repository()
def error_response(
*,
status_code: int,
error: str,
message: str,
) -> JSONResponse:
payload = ErrorDTO(error=error, message=message)
return JSONResponse(
status_code=status_code,
content=payload.model_dump(mode="json"),
)
def parse_task_id(task_id: str) -> UUID | None:
try:
return UUID(task_id)
except (TypeError, ValueError):
return None
def to_task_dto(task: StoredTask) -> TaskDTO:
return TaskDTO.model_validate(task.model_dump(mode="python"))
def to_task_list_item_dto(task: StoredTask) -> TaskListItemDTO:
return TaskListItemDTO.model_validate(task.model_dump(mode="python"))
def protect_csv_cell(value: str) -> str:
stripped = value.lstrip()
if stripped.startswith(("=", "+", "-", "@")):
return f"'{value}"
return value
def build_tasks_csv(tasks: list[StoredTask]) -> str:
buffer = StringIO()
writer = csv.writer(buffer, lineterminator="\n")
writer.writerow(["id", "title", "status", "created_at", "started_at", "done_at"])
for task in tasks:
dto = to_task_list_item_dto(task)
writer.writerow(
[
str(task.id),
protect_csv_cell(task.title),
dto.status,
task.created_at.isoformat(),
task.started_at.isoformat() if task.started_at is not None else "",
task.done_at.isoformat() if task.done_at is not None else "",
],
)
return buffer.getvalue()
def get_task_or_error(
*,
repo: JsonFileTaskRepository,
task_id: str,
) -> tuple[StoredTask | None, JSONResponse | None]:
parsed_task_id = parse_task_id(task_id)
if parsed_task_id is None:
return None, error_response(
status_code=status.HTTP_400_BAD_REQUEST,
error="invalid_id",
message="Task id must be a valid UUID",
)
task = repo.get_task(parsed_task_id)
if task is None:
return None, error_response(
status_code=status.HTTP_404_NOT_FOUND,
error="invalid_id",
message="Task was not found",
)
return task, None
@router.get(
"",
response_model=TaskListDTO,
responses={
400: {"model": ErrorDTO},
},
)
def list_tasks(
limit: int = Query(default=100, ge=1, le=1000),
offset: int = Query(default=0, ge=0),
status_filter: str | None = Query(default=None, alias="status"),
search: str | None = Query(default=None, max_length=100),
repo: JsonFileTaskRepository = Depends(get_task_repository),
) -> TaskListDTO | JSONResponse:
try:
query = TaskQueryDTO(
limit=limit,
offset=offset,
status=status_filter,
search=search,
)
except Exception:
return error_response(
status_code=status.HTTP_400_BAD_REQUEST,
error="invalid_payload",
message="Invalid query parameters",
)
tasks = repo.list_tasks()
if query.status is not None:
tasks = [
task for task in tasks if to_task_list_item_dto(task).status == query.status
]
if query.search is not None:
needle = query.search.casefold()
tasks = [task for task in tasks if needle in task.title.casefold()]
total = len(tasks)
items = tasks[query.offset : query.offset + query.limit]
return TaskListDTO(
items=[to_task_list_item_dto(task) for task in items],
total=total,
limit=query.limit,
offset=query.offset,
)
@router.get("/export")
def export_tasks_csv(
repo: JsonFileTaskRepository = Depends(get_task_repository),
) -> Response:
csv_content = build_tasks_csv(repo.list_tasks())
return Response(
content=csv_content,
media_type="text/csv",
headers={
"Content-Disposition": 'attachment; filename="tasks.csv"',
},
)
@router.get(
"/{task_id}",
response_model=TaskDTO,
responses={
400: {"model": ErrorDTO},
404: {"model": ErrorDTO},
},
)
def get_task(
task_id: str = Path(...),
repo: JsonFileTaskRepository = Depends(get_task_repository),
) -> TaskDTO | JSONResponse:
task, error = get_task_or_error(repo=repo, task_id=task_id)
if error is not None:
return error
return to_task_dto(task)
@router.post(
"",
response_model=TaskDTO,
status_code=status.HTTP_201_CREATED,
responses={
400: {"model": ErrorDTO},
},
)
def create_task(
payload: TaskCreateDTO = Body(...),
repo: JsonFileTaskRepository = Depends(get_task_repository),
) -> TaskDTO | JSONResponse:
now = datetime.now(UTC)
task = StoredTask(
id=uuid4(),
title=payload.title,
created_at=now,
started_at=None,
done_at=None,
)
created_task = repo.create_task(task)
return to_task_dto(created_task)
@router.patch(
"/{task_id}",
response_model=TaskDTO,
responses={
400: {"model": ErrorDTO},
404: {"model": ErrorDTO},
},
)
def update_task(
task_id: str = Path(...),
payload: TaskUpdateDTO = Body(...),
repo: JsonFileTaskRepository = Depends(get_task_repository),
) -> TaskDTO | JSONResponse:
existing_task, error = get_task_or_error(repo=repo, task_id=task_id)
if error is not None:
return error
updated_task = StoredTask(
id=existing_task.id,
title=payload.title if payload.title is not None else existing_task.title,
created_at=existing_task.created_at,
started_at=existing_task.started_at,
done_at=existing_task.done_at,
)
repo.update_task(updated_task)
return to_task_dto(updated_task)
@router.delete(
"/{task_id}",
status_code=status.HTTP_204_NO_CONTENT,
response_class=Response,
responses={
400: {"model": ErrorDTO},
404: {"model": ErrorDTO},
},
)
def delete_task(
task_id: str = Path(...),
repo: JsonFileTaskRepository = Depends(get_task_repository),
) -> Response:
parsed_task_id = parse_task_id(task_id)
if parsed_task_id is None:
return error_response(
status_code=status.HTTP_400_BAD_REQUEST,
error="invalid_id",
message="Task id must be a valid UUID",
)
deleted = repo.delete_task(parsed_task_id)
if not deleted:
return error_response(
status_code=status.HTTP_404_NOT_FOUND,
error="invalid_id",
message="Task was not found",
)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.post(
"/{task_id}/start",
response_model=TaskDTO,
responses={
400: {"model": ErrorDTO},
404: {"model": ErrorDTO},
409: {"model": ErrorDTO},
},
)
def start_task_endpoint(
task_id: str = Path(...),
repo: JsonFileTaskRepository = Depends(get_task_repository),
) -> TaskDTO | JSONResponse:
task, error = get_task_or_error(repo=repo, task_id=task_id)
if error is not None:
return error
try:
updated_task = start_task(task)
except InvalidTransitionError as exc:
return error_response(
status_code=status.HTTP_409_CONFLICT,
error="invalid_transaction",
message=str(exc),
)
repo.update_task(updated_task)
return to_task_dto(updated_task)
@router.post(
"/{task_id}/done",
response_model=TaskDTO,
responses={
400: {"model": ErrorDTO},
404: {"model": ErrorDTO},
409: {"model": ErrorDTO},
},
)
def complete_task_endpoint(
task_id: str = Path(...),
repo: JsonFileTaskRepository = Depends(get_task_repository),
) -> TaskDTO | JSONResponse:
task, error = get_task_or_error(repo=repo, task_id=task_id)
if error is not None:
return error
try:
updated_task = complete_task(task)
except InvalidTransitionError as exc:
return error_response(
status_code=status.HTTP_409_CONFLICT,
error="invalid_transaction",
message=str(exc),
)
repo.update_task(updated_task)
return to_task_dto(updated_task)

8
app/domain/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
from app.domain.tasks import InvalidTransitionError, complete_task, start_task
__all__ = [
"InvalidTransitionError",
"start_task",
"complete_task",
]

60
app/domain/tasks.py Normal file
View File

@@ -0,0 +1,60 @@
from __future__ import annotations
from datetime import datetime, UTC
from app.storage.models import StoredTask
class InvalidTransitionError(Exception):
"""Raised when task workflow transition is invalid."""
def start_task(task: StoredTask) -> StoredTask:
"""
Transition task from backlog → in_progress.
Rules:
- Idempotent: if already started, do not change started_at
- Cannot start a task that is already done
"""
# already done → invalid
if task.done_at is not None:
raise InvalidTransitionError("invalid_transaction")
# already started → idempotent (no change)
if task.started_at is not None:
return task
return StoredTask(
id=task.id,
title=task.title,
created_at=task.created_at,
started_at=datetime.now(UTC),
done_at=task.done_at,
)
def complete_task(task: StoredTask) -> StoredTask:
"""
Transition task from in_progress → done.
Rules:
- Allowed only if task is started
- Idempotent: if already done, do not change done_at
"""
# not started → invalid
if task.started_at is None:
raise InvalidTransitionError("invalid_transaction")
# already done → idempotent
if task.done_at is not None:
return task
return StoredTask(
id=task.id,
title=task.title,
created_at=task.created_at,
started_at=task.started_at,
done_at=datetime.now(UTC),
)

22
app/main.py Normal file
View File

@@ -0,0 +1,22 @@
from fastapi import FastAPI
from app.api.health import router as health_router
from app.api.stats import router as stats_router
from app.api.tasks import router as tasks_router
def create_app() -> FastAPI:
app = FastAPI(
title="TaskFlow",
version="1.0.0",
)
app.include_router(health_router)
app.include_router(tasks_router)
app.include_router(stats_router)
return app
app = create_app()

15
app/storage/__init__.py Normal file
View File

@@ -0,0 +1,15 @@
from app.storage.factory import DEFAULT_TASKS_FILE, create_task_repository
from app.storage.models import DATA_FORMAT_VERSION, StoragePayloadV1, StoredTask
from app.storage.repository import JsonFileTaskRepository, StorageError, StorageTaskNotFoundError
__all__ = [
"DATA_FORMAT_VERSION",
"DEFAULT_TASKS_FILE",
"JsonFileTaskRepository",
"StorageError",
"StoragePayloadV1",
"StorageTaskNotFoundError",
"StoredTask",
"create_task_repository",
]

16
app/storage/factory.py Normal file
View File

@@ -0,0 +1,16 @@
from __future__ import annotations
from pathlib import Path
from app.storage.repository import JsonFileTaskRepository
DEFAULT_DATA_DIR = Path("data")
DEFAULT_TASKS_FILE = DEFAULT_DATA_DIR / "tasks.json"
def create_task_repository(
file_path: str | Path = DEFAULT_TASKS_FILE,
) -> JsonFileTaskRepository:
return JsonFileTaskRepository(file_path=file_path)

58
app/storage/models.py Normal file
View File

@@ -0,0 +1,58 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
DATA_FORMAT_VERSION = 1
class StoredTask(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True)
id: UUID
title: str = Field(..., min_length=1, max_length=100)
created_at: datetime
started_at: datetime | None = None
done_at: datetime | None = None
class StoragePayloadV1(BaseModel):
model_config = ConfigDict(extra="forbid")
version: int = Field(default=DATA_FORMAT_VERSION)
tasks: list[StoredTask] = Field(default_factory=list)
class StoragePayload(BaseModel):
"""
Canonical in-memory storage payload.
This model allows safe parsing of the top-level version field
before delegating to a concrete versioned schema.
"""
model_config = ConfigDict(extra="allow")
version: int
def upgrade_payload(raw_data: dict) -> StoragePayloadV1:
"""
Upgrade any supported payload version to the latest schema.
"""
payload = StoragePayload.model_validate(raw_data)
if payload.version == 1:
parsed_v1 = StoragePayloadV1.model_validate(raw_data)
return StoragePayloadV1(
version=DATA_FORMAT_VERSION,
tasks=parsed_v1.tasks,
)
raise ValueError(
f"Unsupported storage payload version: {payload.version}. "
f"Supported versions: 1..{DATA_FORMAT_VERSION}",
)

167
app/storage/repository.py Normal file
View File

@@ -0,0 +1,167 @@
from __future__ import annotations
import json
import shutil
from pathlib import Path
from threading import RLock
from typing import Iterable
from uuid import UUID
from app.storage.models import DATA_FORMAT_VERSION, StoragePayloadV1, StoredTask, upgrade_payload
class StorageError(Exception):
"""Base storage error."""
class StorageTaskNotFoundError(StorageError):
"""Task was not found in storage."""
class JsonFileTaskRepository:
"""
File-based repository with:
- versioned payload format
- atomic writes via temporary file + replace
- corruption handling with backup + reset
"""
def __init__(self, file_path: str | Path) -> None:
self._file_path = Path(file_path)
self._lock = RLock()
self._ensure_parent_dir()
self._ensure_storage_exists()
@property
def data_format_version(self) -> int:
return DATA_FORMAT_VERSION
def list_tasks(self) -> list[StoredTask]:
with self._lock:
payload = self._load_payload()
return list(payload.tasks)
def get_task(self, task_id: UUID) -> StoredTask | None:
with self._lock:
payload = self._load_payload()
for task in payload.tasks:
if task.id == task_id:
return task
return None
def create_task(self, task: StoredTask) -> StoredTask:
with self._lock:
payload = self._load_payload()
payload.tasks.append(task)
self._save_payload(payload)
return task
def update_task(self, task: StoredTask) -> StoredTask:
with self._lock:
payload = self._load_payload()
updated = False
new_tasks: list[StoredTask] = []
for existing_task in payload.tasks:
if existing_task.id == task.id:
new_tasks.append(task)
updated = True
else:
new_tasks.append(existing_task)
if not updated:
raise StorageTaskNotFoundError(f"Task {task.id} not found")
payload.tasks = new_tasks
self._save_payload(payload)
return task
def delete_task(self, task_id: UUID) -> bool:
with self._lock:
payload = self._load_payload()
initial_count = len(payload.tasks)
payload.tasks = [task for task in payload.tasks if task.id != task_id]
if len(payload.tasks) == initial_count:
return False
self._save_payload(payload)
return True
def replace_all(self, tasks: Iterable[StoredTask]) -> None:
with self._lock:
payload = StoragePayloadV1(
version=DATA_FORMAT_VERSION,
tasks=list(tasks),
)
self._save_payload(payload)
def _ensure_parent_dir(self) -> None:
self._file_path.parent.mkdir(parents=True, exist_ok=True)
def _ensure_storage_exists(self) -> None:
if self._file_path.exists():
return
self._save_payload(
StoragePayloadV1(
version=DATA_FORMAT_VERSION,
tasks=[],
),
)
def _load_payload(self) -> StoragePayloadV1:
if not self._file_path.exists():
payload = StoragePayloadV1(
version=DATA_FORMAT_VERSION,
tasks=[],
)
self._save_payload(payload)
return payload
try:
raw_text = self._file_path.read_text(encoding="utf-8")
raw_data = json.loads(raw_text)
return upgrade_payload(raw_data)
except Exception:
self._backup_corrupted_file()
reset_payload = StoragePayloadV1(
version=DATA_FORMAT_VERSION,
tasks=[],
)
self._save_payload(reset_payload)
return reset_payload
def _save_payload(self, payload: StoragePayloadV1) -> None:
tmp_path = self._file_path.with_name(f"{self._file_path.name}.tmp")
serialized = json.dumps(
payload.model_dump(mode="json"),
ensure_ascii=False,
indent=2,
)
tmp_path.write_text(serialized + "\n", encoding="utf-8")
tmp_path.replace(self._file_path)
def _backup_corrupted_file(self) -> None:
if not self._file_path.exists():
return
backup_path = self._next_backup_path()
shutil.copy2(self._file_path, backup_path)
def _next_backup_path(self) -> Path:
base_name = f"{self._file_path.name}.corrupted"
candidate = self._file_path.with_name(base_name)
if not candidate.exists():
return candidate
index = 1
while True:
candidate = self._file_path.with_name(f"{base_name}.{index}")
if not candidate.exists():
return candidate
index += 1

14
pyproject.toml Normal file
View File

@@ -0,0 +1,14 @@
[project]
name = "task-flow"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"fastapi[standard]>=0.135.2",
]
[dependency-groups]
dev = [
"pytest>=9.0.2",
]

268
templates/stats.html Normal file
View File

@@ -0,0 +1,268 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TaskFlow Stats</title>
<style>
:root {
color-scheme: light;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f5f7fb;
color: #1f2937;
}
.page {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
.title {
margin: 0 0 20px 0;
font-size: 28px;
font-weight: 700;
}
.details-card {
background: #ffffff;
border-radius: 16px;
padding: 20px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
margin-bottom: 24px;
}
.details-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 20px;
}
.details-item {
background: #f8fafc;
border-radius: 12px;
padding: 12px 14px;
}
.details-label {
font-size: 12px;
color: #64748b;
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.details-value {
font-size: 15px;
font-weight: 600;
word-break: break-word;
}
.board {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
}
.column {
background: #ffffff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
min-height: 280px;
}
.column-title {
margin: 0 0 14px 0;
font-size: 18px;
font-weight: 700;
}
.task-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.task-card {
width: 100%;
border: 1px solid #e2e8f0;
background: #f8fafc;
border-radius: 12px;
padding: 12px;
text-align: left;
cursor: pointer;
transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}
.task-card:hover {
transform: translateY(-1px);
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.08);
border-color: #94a3b8;
}
.task-card.active {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
background: #eff6ff;
}
.task-title {
font-size: 15px;
font-weight: 700;
margin-bottom: 8px;
}
.task-meta {
font-size: 12px;
color: #64748b;
}
.empty {
color: #94a3b8;
font-size: 14px;
padding: 8px 2px;
}
@media (max-width: 900px) {
.board {
grid-template-columns: 1fr;
}
.details-grid {
grid-template-columns: 1fr;
}
}
</style>
</head>
<body>
<div class="page">
<h1 class="title">TaskFlow Stats</h1>
<section class="details-card">
<div class="details-grid">
<div class="details-item">
<div class="details-label">Title</div>
<div class="details-value" id="selected-title">{{ selected_task.title if selected_task else "No task selected" }}</div>
</div>
<div class="details-item">
<div class="details-label">Status</div>
<div class="details-value" id="selected-status">{{ selected_task.status if selected_task else "—" }}</div>
</div>
<div class="details-item">
<div class="details-label">Start datetime</div>
<div class="details-value" id="selected-started-at">{{ selected_task.started_at if selected_task and selected_task.started_at else "—" }}</div>
</div>
<div class="details-item">
<div class="details-label">Done datetime</div>
<div class="details-value" id="selected-done-at">{{ selected_task.done_at if selected_task and selected_task.done_at else "—" }}</div>
</div>
<div class="details-item">
<div class="details-label">Cycle time</div>
<div class="details-value" id="selected-cycle-time">{{ selected_task.cycle_time if selected_task else "—" }}</div>
</div>
<div class="details-item">
<div class="details-label">Created at</div>
<div class="details-value" id="selected-created-at">{{ selected_task.created_at if selected_task else "—" }}</div>
</div>
</div>
</section>
<section class="board">
<div class="column">
<h2 class="column-title">Backlog</h2>
<div class="task-list">
{% for task in backlog_tasks %}
<button
type="button"
class="task-card"
data-task='{{ task|tojson }}'
>
<div class="task-title">{{ task.title }}</div>
<div class="task-meta">Created: {{ task.created_at }}</div>
</button>
{% endfor %}
{% if not backlog_tasks %}
<div class="empty">No tasks</div>
{% endif %}
</div>
</div>
<div class="column">
<h2 class="column-title">In Progress</h2>
<div class="task-list">
{% for task in in_progress_tasks %}
<button
type="button"
class="task-card"
data-task='{{ task|tojson }}'
>
<div class="task-title">{{ task.title }}</div>
<div class="task-meta">Started: {{ task.started_at }}</div>
</button>
{% endfor %}
{% if not in_progress_tasks %}
<div class="empty">No tasks</div>
{% endif %}
</div>
</div>
<div class="column">
<h2 class="column-title">Done</h2>
<div class="task-list">
{% for task in done_tasks %}
<button
type="button"
class="task-card"
data-task='{{ task|tojson }}'
>
<div class="task-title">{{ task.title }}</div>
<div class="task-meta">Done: {{ task.done_at }}</div>
</button>
{% endfor %}
{% if not done_tasks %}
<div class="empty">No tasks</div>
{% endif %}
</div>
</div>
</section>
</div>
<script>
const cards = Array.from(document.querySelectorAll(".task-card"));
const selectedTitle = document.getElementById("selected-title");
const selectedStatus = document.getElementById("selected-status");
const selectedStartedAt = document.getElementById("selected-started-at");
const selectedDoneAt = document.getElementById("selected-done-at");
const selectedCycleTime = document.getElementById("selected-cycle-time");
const selectedCreatedAt = document.getElementById("selected-created-at");
function renderTask(task) {
selectedTitle.textContent = task.title || "No task selected";
selectedStatus.textContent = task.status || "—";
selectedStartedAt.textContent = task.started_at || "—";
selectedDoneAt.textContent = task.done_at || "—";
selectedCycleTime.textContent = task.cycle_time || "—";
selectedCreatedAt.textContent = task.created_at || "—";
}
cards.forEach((card, index) => {
if (index === 0) {
card.classList.add("active");
}
card.addEventListener("click", () => {
cards.forEach((item) => item.classList.remove("active"));
card.classList.add("active");
renderTask(JSON.parse(card.dataset.task));
});
});
</script>
</body>
</html>

1
tests/__init__.py Normal file
View File

@@ -0,0 +1 @@

216
tests/test_api_stats.py Normal file
View File

@@ -0,0 +1,216 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from uuid import uuid4
from fastapi.testclient import TestClient
from app.api.tasks import get_task_repository
from app.main import app
from app.storage import JsonFileTaskRepository, StoredTask
def make_repo(tmp_path) -> JsonFileTaskRepository:
return JsonFileTaskRepository(tmp_path / "tasks.json")
def make_task(
*,
title: str = "Task",
started: bool = False,
done: bool = False,
) -> StoredTask:
now = datetime.now(UTC)
started_at = now - timedelta(minutes=10) if started else None
done_at = now - timedelta(minutes=1) if done else None
return StoredTask(
id=uuid4(),
title=title,
created_at=now - timedelta(hours=1),
started_at=started_at,
done_at=done_at,
)
def make_client(tmp_path) -> tuple[TestClient, JsonFileTaskRepository]:
repo = make_repo(tmp_path)
app.dependency_overrides[get_task_repository] = lambda: repo
client = TestClient(app)
return client, repo
def teardown_function() -> None:
app.dependency_overrides.clear()
def test_stats_returns_html_page_for_empty_board(tmp_path):
client, _repo = make_client(tmp_path)
response = client.get("/stats")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "TaskFlow Stats" in response.text
assert "No task selected" in response.text
assert "Backlog" in response.text
assert "In Progress" in response.text
assert "Done" in response.text
assert response.text.count("No tasks") == 3
def test_stats_renders_tasks_in_all_kanban_columns(tmp_path):
client, repo = make_client(tmp_path)
backlog = make_task(title="Backlog title", started=False, done=False)
in_progress = make_task(title="In progress title", started=True, done=False)
done = make_task(title="Done title", started=True, done=True)
repo.create_task(backlog)
repo.create_task(in_progress)
repo.create_task(done)
response = client.get("/stats")
assert response.status_code == 200
assert "Backlog title" in response.text
assert "In progress title" in response.text
assert "Done title" in response.text
assert "Backlog" in response.text
assert "In Progress" in response.text
assert "Done" in response.text
def test_stats_preselects_first_task_in_details_block(tmp_path):
client, repo = make_client(tmp_path)
first = make_task(title="First task", started=False, done=False)
second = make_task(title="Second task", started=True, done=False)
repo.create_task(first)
repo.create_task(second)
response = client.get("/stats")
assert response.status_code == 200
assert 'id="selected-title"' in response.text
assert 'id="selected-created-at"' in response.text
assert "First task" in response.text
assert first.created_at.isoformat() in response.text
def test_stats_shows_placeholders_for_missing_dates(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Backlog only", started=False, done=False)
repo.create_task(task)
response = client.get("/stats")
assert response.status_code == 200
assert 'id="selected-started-at"' in response.text
assert 'id="selected-done-at"' in response.text
assert 'id="selected-cycle-time"' in response.text
assert ">—<" in response.text
def test_stats_renders_cycle_time_for_completed_task(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Completed task", started=True, done=True)
repo.create_task(task)
response = client.get("/stats")
assert response.status_code == 200
assert 'id="selected-cycle-time"' in response.text
assert "0h 9m" in response.text
assert ">—<" not in response.text.split('id="selected-cycle-time"', 1)[1].split("</div>", 2)[1]
def test_stats_embeds_task_payload_for_client_side_switching(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Payload task", started=True, done=True)
repo.create_task(task)
response = client.get("/stats")
assert response.status_code == 200
assert "data-task='" in response.text
assert str(task.id) in response.text
assert '"title": "Payload task"' in response.text
assert '"status": "done"' in response.text
assert '"created_at":' in response.text
assert '"started_at":' in response.text
assert '"done_at":' in response.text
assert '"cycle_time":' in response.text
def test_stats_includes_js_hooks_for_dynamic_selected_task_update(tmp_path):
client, repo = make_client(tmp_path)
repo.create_task(make_task(title="Clickable"))
response = client.get("/stats")
assert response.status_code == 200
assert 'id="selected-title"' in response.text
assert 'id="selected-status"' in response.text
assert 'id="selected-started-at"' in response.text
assert 'id="selected-done-at"' in response.text
assert 'id="selected-cycle-time"' in response.text
assert 'id="selected-created-at"' in response.text
assert 'document.querySelectorAll(".task-card")' in response.text
assert 'card.addEventListener("click"' in response.text
assert "JSON.parse(card.dataset.task)" in response.text
def test_stats_handles_utf8_titles(tmp_path):
client, repo = make_client(tmp_path)
repo.create_task(make_task(title="Задача пример"))
response = client.get("/stats")
assert response.status_code == 200
assert "Задача пример" in response.text
def test_stats_escapes_html_in_task_title(tmp_path):
client, repo = make_client(tmp_path)
repo.create_task(make_task(title="<script>alert(1)</script>"))
response = client.get("/stats")
assert response.status_code == 200
assert "<script>alert(1)</script>" not in response.text
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in response.text
def test_stats_rejects_unsupported_method(tmp_path):
client, _repo = make_client(tmp_path)
response = client.post("/stats")
assert response.status_code == 405
def test_stats_still_works_after_recovery_from_corrupted_file(tmp_path):
file_path = tmp_path / "tasks.json"
file_path.write_text("{broken", encoding="utf-8")
repo = JsonFileTaskRepository(file_path)
app.dependency_overrides[get_task_repository] = lambda: repo
client = TestClient(app)
response = client.get("/stats")
assert response.status_code == 200
assert "text/html" in response.headers["content-type"]
assert "TaskFlow Stats" in response.text
assert "No task selected" in response.text
def test_stats_renders_created_started_and_done_values_for_completed_task(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Detailed task", started=True, done=True)
repo.create_task(task)
response = client.get("/stats")
assert response.status_code == 200
assert task.created_at.isoformat() in response.text
assert task.started_at.isoformat() in response.text
assert task.done_at.isoformat() in response.text

502
tests/test_api_tasks.py Normal file
View File

@@ -0,0 +1,502 @@
from __future__ import annotations
import csv
from datetime import UTC, datetime, timedelta
from uuid import uuid4
from fastapi.testclient import TestClient
from app.api.tasks import get_task_repository
from app.main import app
from app.storage import JsonFileTaskRepository, StoredTask
def make_repo(tmp_path) -> JsonFileTaskRepository:
return JsonFileTaskRepository(tmp_path / "tasks.json")
def make_task(
*,
title: str = "Task",
started: bool = False,
done: bool = False,
) -> StoredTask:
now = datetime.now(UTC)
started_at = now - timedelta(minutes=10) if started else None
done_at = now - timedelta(minutes=1) if done else None
return StoredTask(
id=uuid4(),
title=title,
created_at=now - timedelta(hours=1),
started_at=started_at,
done_at=done_at,
)
def make_client(tmp_path) -> tuple[TestClient, JsonFileTaskRepository]:
repo = make_repo(tmp_path)
app.dependency_overrides[get_task_repository] = lambda: repo
client = TestClient(app)
return client, repo
def teardown_function() -> None:
app.dependency_overrides.clear()
def test_list_tasks_returns_empty_list_by_default(tmp_path):
client, _repo = make_client(tmp_path)
response = client.get("/api/tasks")
assert response.status_code == 200
assert response.json() == {
"items": [],
"total": 0,
"limit": 100,
"offset": 0,
}
def test_list_tasks_filters_by_status_and_search(tmp_path):
client, repo = make_client(tmp_path)
backlog = make_task(title="Alpha backlog")
in_progress = make_task(title="Alpha in progress", started=True, done=False)
done = make_task(title="Beta done", started=True, done=True)
repo.create_task(backlog)
repo.create_task(in_progress)
repo.create_task(done)
response = client.get(
"/api/tasks",
params={"status": "in_progress", "search": "alpha"},
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert len(data["items"]) == 1
assert data["items"][0]["id"] == str(in_progress.id)
assert data["items"][0]["status"] == "in_progress"
def test_list_tasks_rejects_invalid_status_filter(tmp_path):
client, _repo = make_client(tmp_path)
response = client.get("/api/tasks", params={"status": "unknown"})
assert response.status_code == 400
assert response.json()["error"] == "invalid_payload"
def test_get_task_returns_existing_task(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Lookup task")
repo.create_task(task)
response = client.get(f"/api/tasks/{task.id}")
assert response.status_code == 200
data = response.json()
assert data["id"] == str(task.id)
assert data["title"] == task.title
assert data["status"] == "backlog"
def test_get_task_returns_done_status_for_completed_task(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Completed", started=True, done=True)
repo.create_task(task)
response = client.get(f"/api/tasks/{task.id}")
assert response.status_code == 200
assert response.json()["status"] == "done"
def test_get_task_returns_invalid_id_for_bad_uuid(tmp_path):
client, _repo = make_client(tmp_path)
response = client.get("/api/tasks/bad-id")
assert response.status_code == 400
assert response.json() == {
"error": "invalid_id",
"message": "Task id must be a valid UUID",
}
def test_create_task_creates_new_backlog_task(tmp_path):
client, repo = make_client(tmp_path)
response = client.post("/api/tasks", json={"title": "Test task"})
assert response.status_code == 201
data = response.json()
assert data["title"] == "Test task"
assert data["started_at"] is None
assert data["done_at"] is None
assert data["status"] == "backlog"
assert repo.get_task(uuid4()) is None
assert len(repo.list_tasks()) == 1
def test_create_task_trims_title(tmp_path):
client, repo = make_client(tmp_path)
response = client.post("/api/tasks", json={"title": " Trim me "})
assert response.status_code == 201
data = response.json()
assert data["title"] == "Trim me"
assert repo.list_tasks()[0].title == "Trim me"
def test_create_task_rejects_invalid_payload(tmp_path):
client, _repo = make_client(tmp_path)
response = client.post("/api/tasks", json={"title": ""})
assert response.status_code == 422
def test_update_task_updates_title(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Old title")
repo.create_task(task)
response = client.patch(
f"/api/tasks/{task.id}",
json={"title": "Updated title"},
)
assert response.status_code == 200
data = response.json()
assert data["title"] == "Updated title"
reloaded = repo.get_task(task.id)
assert reloaded is not None
assert reloaded.title == "Updated title"
assert reloaded.created_at == task.created_at
def test_update_task_keeps_existing_timestamps(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Workflow", started=True, done=True)
repo.create_task(task)
response = client.patch(
f"/api/tasks/{task.id}",
json={"title": "Workflow updated"},
)
assert response.status_code == 200
data = response.json()
assert data["title"] == "Workflow updated"
assert datetime.fromisoformat(data["started_at"]) == task.started_at
assert datetime.fromisoformat(data["done_at"]) == task.done_at
def test_update_task_returns_not_found_for_missing_task(tmp_path):
client, _repo = make_client(tmp_path)
response = client.patch(
f"/api/tasks/{uuid4()}",
json={"title": "Updated"},
)
assert response.status_code == 404
assert response.json()["error"] == "invalid_id"
def test_delete_task_removes_existing_task(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Delete me")
repo.create_task(task)
response = client.delete(f"/api/tasks/{task.id}")
assert response.status_code == 204
assert repo.get_task(task.id) is None
def test_delete_task_only_removes_target_task(tmp_path):
client, repo = make_client(tmp_path)
task_1 = make_task(title="Keep me")
task_2 = make_task(title="Delete me")
repo.create_task(task_1)
repo.create_task(task_2)
response = client.delete(f"/api/tasks/{task_2.id}")
assert response.status_code == 204
remaining = repo.list_tasks()
assert remaining == [task_1]
def test_delete_task_returns_invalid_id_for_bad_uuid(tmp_path):
client, _repo = make_client(tmp_path)
response = client.delete("/api/tasks/bad-id")
assert response.status_code == 400
assert response.json()["error"] == "invalid_id"
def test_start_task_transitions_backlog_to_in_progress(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Start me")
repo.create_task(task)
before_call = datetime.now(UTC)
response = client.post(f"/api/tasks/{task.id}/start")
after_call = datetime.now(UTC)
assert response.status_code == 200
data = response.json()
assert data["status"] == "in_progress"
assert data["done_at"] is None
started_at = datetime.fromisoformat(data["started_at"])
assert before_call <= started_at <= after_call
def test_start_task_is_idempotent(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Already started", started=True, done=False)
repo.create_task(task)
response = client.post(f"/api/tasks/{task.id}/start")
assert response.status_code == 200
data = response.json()
assert data["status"] == "in_progress"
assert datetime.fromisoformat(data["started_at"]) == task.started_at
def test_start_task_rejects_completed_task(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Completed", started=True, done=True)
repo.create_task(task)
response = client.post(f"/api/tasks/{task.id}/start")
assert response.status_code == 409
assert response.json()["error"] == "invalid_transaction"
def test_done_task_transitions_in_progress_to_done(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Complete me", started=True, done=False)
repo.create_task(task)
before_call = datetime.now(UTC)
response = client.post(f"/api/tasks/{task.id}/done")
after_call = datetime.now(UTC)
assert response.status_code == 200
data = response.json()
assert data["status"] == "done"
assert datetime.fromisoformat(data["started_at"]) == task.started_at
done_at = datetime.fromisoformat(data["done_at"])
assert before_call <= done_at <= after_call
assert done_at >= task.started_at
def test_done_task_is_idempotent_for_completed_task(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Already done", started=True, done=True)
repo.create_task(task)
response = client.post(f"/api/tasks/{task.id}/done")
assert response.status_code == 200
data = response.json()
assert data["status"] == "done"
assert datetime.fromisoformat(data["done_at"]) == task.done_at
def test_done_task_rejects_backlog_task(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Backlog")
repo.create_task(task)
response = client.post(f"/api/tasks/{task.id}/done")
assert response.status_code == 409
assert response.json()["error"] == "invalid_transaction"
def test_list_tasks_rejects_invalid_limit(tmp_path):
client, _repo = make_client(tmp_path)
response = client.get("/api/tasks", params={"limit": 0})
assert response.status_code == 422
def test_get_task_returns_not_found_for_missing_uuid(tmp_path):
client, _repo = make_client(tmp_path)
response = client.get(f"/api/tasks/{uuid4()}")
assert response.status_code == 404
assert response.json()["error"] == "invalid_id"
def test_patch_task_rejects_invalid_payload(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Patch me")
repo.create_task(task)
response = client.patch(
f"/api/tasks/{task.id}",
json={"title": ""},
)
assert response.status_code == 422
def test_delete_task_returns_not_found_for_missing_uuid(tmp_path):
client, _repo = make_client(tmp_path)
response = client.delete(f"/api/tasks/{uuid4()}")
assert response.status_code == 404
assert response.json()["error"] == "invalid_id"
def test_start_task_returns_not_found_for_missing_uuid(tmp_path):
client, _repo = make_client(tmp_path)
response = client.post(f"/api/tasks/{uuid4()}/start")
assert response.status_code == 404
assert response.json()["error"] == "invalid_id"
def test_done_task_returns_not_found_for_missing_uuid(tmp_path):
client, _repo = make_client(tmp_path)
response = client.post(f"/api/tasks/{uuid4()}/done")
assert response.status_code == 404
assert response.json()["error"] == "invalid_id"
def test_export_tasks_csv_returns_header_only_for_empty_storage(tmp_path):
client, _repo = make_client(tmp_path)
response = client.get("/api/tasks/export")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/csv")
assert response.headers["content-disposition"] == 'attachment; filename="tasks.csv"'
assert response.text == "id,title,status,created_at,started_at,done_at\n"
def test_export_tasks_csv_returns_rows_for_multiple_statuses(tmp_path):
client, repo = make_client(tmp_path)
backlog = make_task(title="Backlog task", started=False, done=False)
in_progress = make_task(title="In progress task", started=True, done=False)
done = make_task(title="Done task", started=True, done=True)
repo.create_task(backlog)
repo.create_task(in_progress)
repo.create_task(done)
response = client.get("/api/tasks/export")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/csv")
rows = list(csv.DictReader(response.text.splitlines()))
assert len(rows) == 3
by_id = {row["id"]: row for row in rows}
assert by_id[str(backlog.id)]["title"] == "Backlog task"
assert by_id[str(backlog.id)]["status"] == "backlog"
assert by_id[str(backlog.id)]["started_at"] == ""
assert by_id[str(backlog.id)]["done_at"] == ""
assert by_id[str(in_progress.id)]["title"] == "In progress task"
assert by_id[str(in_progress.id)]["status"] == "in_progress"
assert datetime.fromisoformat(by_id[str(in_progress.id)]["created_at"]) == in_progress.created_at
assert datetime.fromisoformat(by_id[str(in_progress.id)]["started_at"]) == in_progress.started_at
assert by_id[str(in_progress.id)]["done_at"] == ""
assert by_id[str(done.id)]["title"] == "Done task"
assert by_id[str(done.id)]["status"] == "done"
assert datetime.fromisoformat(by_id[str(done.id)]["created_at"]) == done.created_at
assert datetime.fromisoformat(by_id[str(done.id)]["started_at"]) == done.started_at
assert datetime.fromisoformat(by_id[str(done.id)]["done_at"]) == done.done_at
def test_export_tasks_csv_protects_against_csv_injection(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="=2+2")
repo.create_task(task)
response = client.get("/api/tasks/export")
assert response.status_code == 200
rows = list(csv.DictReader(response.text.splitlines()))
assert len(rows) == 1
assert rows[0]["title"] == "'=2+2"
def test_export_tasks_csv_preserves_utf8_titles(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Задача пример")
repo.create_task(task)
response = client.get("/api/tasks/export")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/csv")
assert "Задача пример" in response.text
def test_export_tasks_csv_uses_empty_strings_for_absent_timestamps(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="No timestamps", started=False, done=False)
repo.create_task(task)
response = client.get("/api/tasks/export")
assert response.status_code == 200
rows = list(csv.DictReader(response.text.splitlines()))
assert len(rows) == 1
assert rows[0]["started_at"] == ""
assert rows[0]["done_at"] == ""
def test_export_tasks_csv_is_stable_across_repeated_calls(tmp_path):
client, repo = make_client(tmp_path)
task = make_task(title="Stable export", started=True, done=True)
repo.create_task(task)
response_1 = client.get("/api/tasks/export")
response_2 = client.get("/api/tasks/export")
assert response_1.status_code == 200
assert response_2.status_code == 200
assert response_1.headers["content-type"].startswith("text/csv")
assert response_2.headers["content-type"].startswith("text/csv")
assert response_1.text == response_2.text
def test_export_tasks_csv_rejects_unsupported_method(tmp_path):
client, _repo = make_client(tmp_path)
response = client.post("/api/tasks/export")
assert response.status_code == 405
def test_export_route_is_not_shadowed_by_task_id_route(tmp_path):
client, _repo = make_client(tmp_path)
response = client.get("/api/tasks/export")
assert response.status_code == 200
assert response.headers["content-type"].startswith("text/csv")

View File

@@ -0,0 +1,96 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from app.domain import InvalidTransitionError, complete_task, start_task
from app.storage import StoredTask
def make_task(
*,
title: str = "Task",
started_at: datetime | None = None,
done_at: datetime | None = None,
) -> StoredTask:
return StoredTask(
id=uuid4(),
title=title,
created_at=datetime.now(UTC),
started_at=started_at,
done_at=done_at,
)
def test_start_task_from_backlog_sets_started_at():
task = make_task(started_at=None, done_at=None)
before_call = datetime.now(UTC)
result = start_task(task)
after_call = datetime.now(UTC)
assert result.id == task.id
assert result.title == task.title
assert result.created_at == task.created_at
assert result.done_at is None
assert result.started_at is not None
assert before_call <= result.started_at <= after_call
def test_start_task_is_idempotent_for_already_started_task():
started_at = datetime.now(UTC) - timedelta(minutes=5)
task = make_task(started_at=started_at, done_at=None)
result = start_task(task)
assert result == task
assert result.started_at == started_at
def test_start_task_raises_for_completed_task():
started_at = datetime.now(UTC) - timedelta(minutes=10)
done_at = datetime.now(UTC) - timedelta(minutes=1)
task = make_task(started_at=started_at, done_at=done_at)
with pytest.raises(InvalidTransitionError, match="invalid_transaction"):
start_task(task)
def test_complete_task_from_in_progress_sets_done_at():
started_at = datetime.now(UTC) - timedelta(minutes=10)
task = make_task(started_at=started_at, done_at=None)
before_call = datetime.now(UTC)
result = complete_task(task)
after_call = datetime.now(UTC)
assert result.id == task.id
assert result.title == task.title
assert result.created_at == task.created_at
assert result.started_at == started_at
assert result.done_at is not None
assert result.started_at <= result.done_at
assert before_call <= result.done_at <= after_call
def test_complete_task_is_idempotent_for_already_completed_task():
started_at = datetime.now(UTC) - timedelta(minutes=10)
done_at = datetime.now(UTC) - timedelta(minutes=1)
task = make_task(started_at=started_at, done_at=done_at)
result = complete_task(task)
assert result == task
assert result.done_at == done_at
def test_complete_task_raises_for_backlog_task():
task = make_task(started_at=None, done_at=None)
with pytest.raises(InvalidTransitionError, match="invalid_transaction"):
complete_task(task)

45
tests/test_health.py Normal file
View File

@@ -0,0 +1,45 @@
from datetime import datetime
import pytest
from fastapi.testclient import TestClient
from app.main import app
client = TestClient(app)
def test_health_ok():
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert "server_time" in data
# Validate ISO-8601 format (basic check via fromisoformat)
parsed = datetime.fromisoformat(data["server_time"])
assert isinstance(parsed, datetime)
def test_health_idempotent_time_increases():
response1 = client.get("/health")
response2 = client.get("/health")
assert response1.status_code == 200
assert response2.status_code == 200
data1 = response1.json()
data2 = response2.json()
t1 = datetime.fromisoformat(data1["server_time"])
t2 = datetime.fromisoformat(data2["server_time"])
assert t2 >= t1
def test_health_method_not_allowed():
response = client.post("/health")
assert response.status_code == 405

View File

@@ -0,0 +1,83 @@
from __future__ import annotations
import json
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4
import pytest
from app.storage import DATA_FORMAT_VERSION, StoredTask
from app.storage.repository import JsonFileTaskRepository
def make_task(title: str = "Task") -> StoredTask:
return StoredTask(
id=uuid4(),
title=title,
created_at=datetime.now(UTC),
started_at=None,
done_at=None,
)
def test_interrupted_save_before_rename_keeps_original_data_intact(tmp_path, monkeypatch):
file_path = tmp_path / "tasks.json"
repo = JsonFileTaskRepository(file_path)
original_task = make_task("Original")
repo.create_task(original_task)
replacement_called = False
real_replace = Path.replace
def failing_replace(self: Path, target: Path):
nonlocal replacement_called
replacement_called = True
raise RuntimeError("simulated crash before rename")
monkeypatch.setattr(Path, "replace", failing_replace)
with pytest.raises(RuntimeError, match="simulated crash before rename"):
repo.create_task(make_task("New task"))
assert replacement_called is True
reloaded_repo = JsonFileTaskRepository(file_path)
assert reloaded_repo.list_tasks() == [original_task]
tmp_file = tmp_path / "tasks.json.tmp"
assert tmp_file.exists()
monkeypatch.setattr(Path, "replace", real_replace)
def test_normal_save_replaces_file_with_complete_payload(tmp_path):
file_path = tmp_path / "tasks.json"
repo = JsonFileTaskRepository(file_path)
task_1 = make_task("One")
task_2 = make_task("Two")
repo.create_task(task_1)
repo.create_task(task_2)
payload = json.loads(file_path.read_text(encoding="utf-8"))
assert payload["version"] == DATA_FORMAT_VERSION
assert len(payload["tasks"]) == 2
assert {item["id"] for item in payload["tasks"]} == {str(task_1.id), str(task_2.id)}
def test_stale_tmp_file_does_not_break_startup(tmp_path):
file_path = tmp_path / "tasks.json"
repo_1 = JsonFileTaskRepository(file_path)
task = make_task("Persisted")
repo_1.create_task(task)
stale_tmp = tmp_path / "tasks.json.tmp"
stale_tmp.write_text('{"version": 999, "tasks": "broken"}', encoding="utf-8")
repo_2 = JsonFileTaskRepository(file_path)
assert repo_2.list_tasks() == [task]
assert stale_tmp.exists()

View File

@@ -0,0 +1,205 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from uuid import uuid4
import pytest
from pydantic import ValidationError
from app.storage import StoredTask
from app.storage.repository import JsonFileTaskRepository, StorageTaskNotFoundError
def make_task(
*,
title: str = "Task",
started: bool = False,
done: bool = False,
) -> StoredTask:
now = datetime.now(UTC)
started_at = now + timedelta(seconds=1) if started else None
done_at = now + timedelta(seconds=2) if done else None
return StoredTask(
id=uuid4(),
title=title,
created_at=now,
started_at=started_at,
done_at=done_at,
)
def make_repo(tmp_path) -> JsonFileTaskRepository:
return JsonFileTaskRepository(tmp_path / "tasks.json")
def test_create_task_in_empty_storage(tmp_path):
repo = make_repo(tmp_path)
task = make_task(title="First task")
created = repo.create_task(task)
tasks = repo.list_tasks()
assert created == task
assert len(tasks) == 1
assert tasks[0] == task
def test_create_task_sequentially_preserves_insertion_order(tmp_path):
repo = make_repo(tmp_path)
task_1 = make_task(title="First")
task_2 = make_task(title="Second")
repo.create_task(task_1)
repo.create_task(task_2)
tasks = repo.list_tasks()
assert [task.id for task in tasks] == [task_1.id, task_2.id]
def test_create_task_invalid_model_rejected_before_repository_call(tmp_path):
repo = make_repo(tmp_path)
with pytest.raises(ValidationError):
StoredTask(
id=uuid4(),
title="",
created_at=datetime.now(UTC),
)
assert repo.list_tasks() == []
def test_list_tasks_returns_empty_list_for_fresh_storage(tmp_path):
repo = make_repo(tmp_path)
assert repo.list_tasks() == []
def test_list_tasks_returns_persisted_tasks(tmp_path):
repo = make_repo(tmp_path)
task_1 = make_task(title="One")
task_2 = make_task(title="Two")
repo.create_task(task_1)
repo.create_task(task_2)
tasks = repo.list_tasks()
assert tasks == [task_1, task_2]
def test_get_task_returns_existing_task(tmp_path):
repo = make_repo(tmp_path)
task = make_task(title="Lookup me")
repo.create_task(task)
found = repo.get_task(task.id)
assert found == task
def test_get_task_returns_none_for_missing_id(tmp_path):
repo = make_repo(tmp_path)
repo.create_task(make_task(title="Other task"))
found = repo.get_task(uuid4())
assert found is None
def test_get_task_with_wrong_runtime_type_returns_none_and_does_not_change_storage(tmp_path):
repo = make_repo(tmp_path)
task = make_task(title="Type safety")
repo.create_task(task)
found = repo.get_task("not-a-uuid") # type: ignore[arg-type]
assert found is None
assert repo.list_tasks() == [task]
def test_update_task_replaces_existing_fields(tmp_path):
repo = make_repo(tmp_path)
original = make_task(title="Old title")
repo.create_task(original)
updated = StoredTask(
id=original.id,
title="New title",
created_at=original.created_at,
started_at=original.started_at,
done_at=original.done_at,
)
result = repo.update_task(updated)
assert result == updated
assert repo.get_task(original.id) == updated
def test_update_task_persists_transition_related_fields(tmp_path):
repo = make_repo(tmp_path)
original = make_task(title="Workflow")
repo.create_task(original)
now = datetime.now(UTC)
updated = StoredTask(
id=original.id,
title=original.title,
created_at=original.created_at,
started_at=now,
done_at=now + timedelta(minutes=5),
)
repo.update_task(updated)
reloaded = repo.get_task(original.id)
assert reloaded is not None
assert reloaded.started_at == updated.started_at
assert reloaded.done_at == updated.done_at
def test_update_task_raises_for_missing_task(tmp_path):
repo = make_repo(tmp_path)
missing = make_task(title="Missing")
with pytest.raises(StorageTaskNotFoundError):
repo.update_task(missing)
assert repo.list_tasks() == []
def test_delete_task_removes_existing_task(tmp_path):
repo = make_repo(tmp_path)
task = make_task(title="Delete me")
repo.create_task(task)
deleted = repo.delete_task(task.id)
assert deleted is True
assert repo.list_tasks() == []
def test_delete_task_only_affects_requested_task(tmp_path):
repo = make_repo(tmp_path)
task_1 = make_task(title="Keep me")
task_2 = make_task(title="Delete me")
repo.create_task(task_1)
repo.create_task(task_2)
deleted = repo.delete_task(task_2.id)
assert deleted is True
assert repo.list_tasks() == [task_1]
def test_delete_task_returns_false_for_missing_task(tmp_path):
repo = make_repo(tmp_path)
task = make_task(title="Existing")
repo.create_task(task)
deleted = repo.delete_task(uuid4())
assert deleted is False
assert repo.list_tasks() == [task]

View File

@@ -0,0 +1,146 @@
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from uuid import uuid4
from app.storage import DATA_FORMAT_VERSION, StoredTask
from app.storage.repository import JsonFileTaskRepository
def make_task(
*,
title: str = "Task",
started: bool = False,
done: bool = False,
) -> StoredTask:
now = datetime.now(UTC)
started_at = now + timedelta(seconds=1) if started else None
done_at = now + timedelta(seconds=2) if done else None
return StoredTask(
id=uuid4(),
title=title,
created_at=now,
started_at=started_at,
done_at=done_at,
)
def test_replace_all_replaces_empty_state_with_provided_tasks(tmp_path):
repo = JsonFileTaskRepository(tmp_path / "tasks.json")
task_1 = make_task(title="One")
task_2 = make_task(title="Two")
repo.replace_all([task_1, task_2])
assert repo.list_tasks() == [task_1, task_2]
def test_replace_all_fully_overwrites_previous_content(tmp_path):
repo = JsonFileTaskRepository(tmp_path / "tasks.json")
old_task = make_task(title="Old")
new_task = make_task(title="New")
repo.create_task(old_task)
repo.replace_all([new_task])
assert repo.list_tasks() == [new_task]
def test_data_format_version_matches_constant(tmp_path):
repo = JsonFileTaskRepository(tmp_path / "tasks.json")
assert repo.data_format_version == DATA_FORMAT_VERSION
def test_data_format_version_is_stable_across_instances(tmp_path):
file_path = tmp_path / "tasks.json"
repo_1 = JsonFileTaskRepository(file_path)
repo_2 = JsonFileTaskRepository(file_path)
assert repo_1.data_format_version == repo_2.data_format_version == DATA_FORMAT_VERSION
def test_missing_file_is_created_automatically(tmp_path):
file_path = tmp_path / "nested" / "tasks.json"
repo = JsonFileTaskRepository(file_path)
assert repo.list_tasks() == []
assert file_path.exists()
def test_existing_valid_file_is_loaded_on_new_instance(tmp_path):
file_path = tmp_path / "tasks.json"
repo_1 = JsonFileTaskRepository(file_path)
task = make_task(title="Persisted")
repo_1.create_task(task)
repo_2 = JsonFileTaskRepository(file_path)
assert repo_2.list_tasks() == [task]
def test_parent_directory_is_created_if_missing(tmp_path):
file_path = tmp_path / "deep" / "nested" / "data" / "tasks.json"
repo = JsonFileTaskRepository(file_path)
assert file_path.parent.exists()
assert repo.list_tasks() == []
def test_current_version_payload_loads_as_is(tmp_path):
file_path = tmp_path / "tasks.json"
repo_1 = JsonFileTaskRepository(file_path)
task = make_task(title="Versioned")
repo_1.create_task(task)
repo_2 = JsonFileTaskRepository(file_path)
assert repo_2.data_format_version == DATA_FORMAT_VERSION
assert repo_2.list_tasks() == [task]
def test_empty_current_version_payload_loads_without_backup(tmp_path):
file_path = tmp_path / "tasks.json"
repo = JsonFileTaskRepository(file_path)
assert repo.list_tasks() == []
assert not list(tmp_path.glob("tasks.json.corrupted*"))
def test_persistence_between_repository_instances(tmp_path):
file_path = tmp_path / "tasks.json"
repo_1 = JsonFileTaskRepository(file_path)
task = make_task(title="Cross-session")
repo_1.create_task(task)
repo_2 = JsonFileTaskRepository(file_path)
assert repo_2.get_task(task.id) == task
def test_multiple_operations_survive_restart(tmp_path):
file_path = tmp_path / "tasks.json"
repo_1 = JsonFileTaskRepository(file_path)
task_1 = make_task(title="First")
task_2 = make_task(title="Second")
repo_1.create_task(task_1)
repo_1.create_task(task_2)
repo_1.delete_task(task_1.id)
updated_task_2 = StoredTask(
id=task_2.id,
title="Second updated",
created_at=task_2.created_at,
started_at=datetime.now(UTC),
done_at=None,
)
repo_1.update_task(updated_task_2)
repo_2 = JsonFileTaskRepository(file_path)
assert repo_2.list_tasks() == [updated_task_2]

View File

@@ -0,0 +1,102 @@
from __future__ import annotations
import json
from datetime import UTC, datetime
from uuid import uuid4
from app.storage import DATA_FORMAT_VERSION, StoredTask
from app.storage.repository import JsonFileTaskRepository
def make_task(title: str = "Task") -> StoredTask:
return StoredTask(
id=uuid4(),
title=title,
created_at=datetime.now(UTC),
started_at=None,
done_at=None,
)
def test_corrupted_json_triggers_backup_and_reset(tmp_path):
file_path = tmp_path / "tasks.json"
file_path.write_text('{"version": 1, "tasks": [', encoding="utf-8")
repo = JsonFileTaskRepository(file_path)
assert repo.list_tasks() == []
backups = list(tmp_path.glob("tasks.json.corrupted*"))
assert len(backups) == 1
assert backups[0].read_text(encoding="utf-8") == '{"version": 1, "tasks": ['
restored = json.loads(file_path.read_text(encoding="utf-8"))
assert restored["version"] == DATA_FORMAT_VERSION
assert restored["tasks"] == []
def test_invalid_schema_triggers_backup_and_reset(tmp_path):
file_path = tmp_path / "tasks.json"
file_path.write_text(
json.dumps(
{
"version": 1,
"tasks": [
{
"id": str(uuid4()),
"created_at": datetime.now(UTC).isoformat(),
},
],
},
),
encoding="utf-8",
)
repo = JsonFileTaskRepository(file_path)
assert repo.list_tasks() == []
backups = list(tmp_path.glob("tasks.json.corrupted*"))
assert len(backups) == 1
def test_repository_remains_writable_after_recovery(tmp_path):
file_path = tmp_path / "tasks.json"
file_path.write_text("not valid json", encoding="utf-8")
repo = JsonFileTaskRepository(file_path)
task = make_task("Recovered")
repo.create_task(task)
assert repo.list_tasks() == [task]
def test_unsupported_version_creates_backup_and_resets_state(tmp_path):
file_path = tmp_path / "tasks.json"
file_path.write_text(
json.dumps({"version": 999, "tasks": []}),
encoding="utf-8",
)
repo = JsonFileTaskRepository(file_path)
assert repo.list_tasks() == []
backups = list(tmp_path.glob("tasks.json.corrupted*"))
assert len(backups) == 1
restored = json.loads(file_path.read_text(encoding="utf-8"))
assert restored["version"] == DATA_FORMAT_VERSION
assert restored["tasks"] == []
def test_restart_after_corrupted_persisted_state_does_not_crash(tmp_path):
file_path = tmp_path / "tasks.json"
repo_1 = JsonFileTaskRepository(file_path)
repo_1.create_task(make_task("Initial"))
file_path.write_text("{broken", encoding="utf-8")
repo_2 = JsonFileTaskRepository(file_path)
assert repo_2.list_tasks() == []
assert len(list(tmp_path.glob("tasks.json.corrupted*"))) == 1

766
uv.lock generated Normal file
View File

@@ -0,0 +1,766 @@
version = 1
revision = 3
requires-python = ">=3.14"
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
]
[[package]]
name = "anyio"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
]
[[package]]
name = "certifi"
version = "2026.2.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
]
[[package]]
name = "click"
version = "8.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
]
[[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 = "dnspython"
version = "2.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" },
]
[[package]]
name = "email-validator"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "dnspython" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" },
]
[[package]]
name = "fastapi"
version = "0.135.2"
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/c4/73/5903c4b13beae98618d64eb9870c3fac4f605523dd0312ca5c80dadbd5b9/fastapi-0.135.2.tar.gz", hash = "sha256:88a832095359755527b7f63bb4c6bc9edb8329a026189eed83d6c1afcf419d56", size = 395833, upload-time = "2026-03-23T14:12:41.697Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/ea/18f6d0457f9efb2fc6fa594857f92810cadb03024975726db6546b3d6fcf/fastapi-0.135.2-py3-none-any.whl", hash = "sha256:0af0447d541867e8db2a6a25c23a8c4bd80e2394ac5529bd87501bbb9e240ca5", size = 117407, upload-time = "2026-03-23T14:12:43.284Z" },
]
[package.optional-dependencies]
standard = [
{ name = "email-validator" },
{ name = "fastapi-cli", extra = ["standard"] },
{ name = "httpx" },
{ name = "jinja2" },
{ name = "pydantic-extra-types" },
{ name = "pydantic-settings" },
{ name = "python-multipart" },
{ name = "uvicorn", extra = ["standard"] },
]
[[package]]
name = "fastapi-cli"
version = "0.0.24"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "rich-toolkit" },
{ name = "typer" },
{ name = "uvicorn", extra = ["standard"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/6e/58/74797ae9e4610cfa0c6b34c8309096d3b20bb29be3b8b5fbf1004d10fa5f/fastapi_cli-0.0.24.tar.gz", hash = "sha256:1afc9c9e21d7ebc8a3ca5e31790cd8d837742be7e4f8b9236e99cb3451f0de00", size = 19043, upload-time = "2026-02-24T10:45:10.476Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/4b/68f9fe268e535d79c76910519530026a4f994ce07189ac0dded45c6af825/fastapi_cli-0.0.24-py3-none-any.whl", hash = "sha256:4a1f78ed798f106b4fee85ca93b85d8fe33c0a3570f775964d37edb80b8f0edc", size = 12304, upload-time = "2026-02-24T10:45:09.552Z" },
]
[package.optional-dependencies]
standard = [
{ name = "fastapi-cloud-cli" },
{ name = "uvicorn", extra = ["standard"] },
]
[[package]]
name = "fastapi-cloud-cli"
version = "0.15.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastar" },
{ name = "httpx" },
{ name = "pydantic", extra = ["email"] },
{ name = "rich-toolkit" },
{ name = "rignore" },
{ name = "sentry-sdk" },
{ name = "typer" },
{ name = "uvicorn", extra = ["standard"] },
]
sdist = { url = "https://files.pythonhosted.org/packages/7f/f2/fcd66ce245b7e3c3d84ca8717eda8896945fbc17c87a9b03f490ff06ace7/fastapi_cloud_cli-0.15.1.tar.gz", hash = "sha256:71a46f8a1d9fea295544113d6b79f620dc5768b24012887887306d151165745d", size = 43851, upload-time = "2026-03-26T10:23:12.932Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b2/11/ecb0d5e1d114e8aaec1cdc8ee2d7b0f54292585067effe2756bde7e7a4b0/fastapi_cloud_cli-0.15.1-py3-none-any.whl", hash = "sha256:b1e8b3b26dc314e180fc0ab67dfd39d7d9fe160d3951081d09184eafaacf5649", size = 32284, upload-time = "2026-03-26T10:23:14.151Z" },
]
[[package]]
name = "fastar"
version = "0.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/dd/00/dab9ca274cf1fde19223fea7104631bea254751026e75bf99f2b6d0d1568/fastar-0.9.0.tar.gz", hash = "sha256:d49114d5f0b76c5cc242875d90fa4706de45e0456ddedf416608ecd0787fb410", size = 70124, upload-time = "2026-03-20T14:26:34.503Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cf/00/99700dd33273c118d7d9ab7ad5db6650b430448d4cfae62aec6ef6ca4cb7/fastar-0.9.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:ccb2289f24ee6555330eb77149486d3a2ec8926450a96157dd20c636a0eec085", size = 707059, upload-time = "2026-03-20T14:25:35.086Z" },
{ url = "https://files.pythonhosted.org/packages/e9/a4/4808dcfa8dddb9d7f50d830a39a9084d9d148ed06fcac8b040620848bc24/fastar-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2bfee749a46666785151b33980aef8f916e6e0341c3d241bde4d3de6be23f00c", size = 627135, upload-time = "2026-03-20T14:25:23.134Z" },
{ url = "https://files.pythonhosted.org/packages/da/cb/9c92e97d760d769846cae6ce53332a5f2a9246eb07b369ac2a4ebf10480c/fastar-0.9.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:f6096ec3f216a21fa9ac430ce509447f56c5bd979170c4c0c3b4f3cb2051c1a8", size = 864974, upload-time = "2026-03-20T14:24:58.624Z" },
{ url = "https://files.pythonhosted.org/packages/84/38/9dadebd0b7408b4f415827db35169bbd0741e726e38e3afd3e491b589c61/fastar-0.9.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7a806e54d429f7f57e35dc709e801da8c0ba9095deb7331d6574c05ae4537ea", size = 760262, upload-time = "2026-03-20T14:23:53.275Z" },
{ url = "https://files.pythonhosted.org/packages/d6/7d/7afc5721429515aa0873b268513f656f905d27ff1ca54d875af6be9e9bc6/fastar-0.9.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9a06abf8c7f74643a75003334683eb6e94fabef05f60449b7841eeb093a47b0", size = 757575, upload-time = "2026-03-20T14:24:06.143Z" },
{ url = "https://files.pythonhosted.org/packages/fc/5d/7498842c62bd6057553aa598cd175a0db41fdfeda7bdfde48dab63ffb285/fastar-0.9.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1e9b5c155946f20ce3f999fb1362ed102876156ad6539e1b73a921f14efb758c", size = 924827, upload-time = "2026-03-20T14:24:19.364Z" },
{ url = "https://files.pythonhosted.org/packages/69/ab/13322e98fe1a00ed6efbfa5bf06fcfff8a6979804ef7fcef884b5e0c6f85/fastar-0.9.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fbdedac6a84ef9ebc1cee6d777599ad51c9e98ceb8ebb386159483dcd60d0e16", size = 816536, upload-time = "2026-03-20T14:24:44.844Z" },
{ url = "https://files.pythonhosted.org/packages/fe/fd/0aa5b9994c8dba75b73a9527be4178423cb926db9f7eca562559e27ccdfd/fastar-0.9.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51df60a2f7af09f75b2a4438b25cb903d8774e24c492acf2bca8b0863026f34c", size = 818686, upload-time = "2026-03-20T14:25:10.799Z" },
{ url = "https://files.pythonhosted.org/packages/46/d6/e000cd49ef85c11a8350e461e6c48a4345ace94fb52242ac8c1d5dad1dfc/fastar-0.9.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:15016d0da7dbc664f09145fc7db549ba8fe32628c6e44e20926655b82de10658", size = 885043, upload-time = "2026-03-20T14:24:32.231Z" },
{ url = "https://files.pythonhosted.org/packages/68/28/ee734fe273475b9b25554370d92a21fc809376cf79aa072de29d23c17518/fastar-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c66a8e1f7dae6357be8c1f83ce6330febbc08e49fc40a5a2e91061e7867bbcbf", size = 967965, upload-time = "2026-03-20T14:25:48.397Z" },
{ url = "https://files.pythonhosted.org/packages/c1/35/165b3a75f1ee8045af9478c8aae5b5e20913cca2d4a5adb1be445e8d015a/fastar-0.9.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1c6829be3f55d2978cb62921ef4d7c3dd58fe68ee994f81d49bd0a3c5240c977", size = 1034507, upload-time = "2026-03-20T14:26:01.518Z" },
{ url = "https://files.pythonhosted.org/packages/ba/4e/4097b5015da02484468c16543db2f8dec2fe827d321a798acbd9068e0f13/fastar-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:68db849e01d49543f31d56ef2fe15527afe2b9e0fb21794edc4d772553d83407", size = 1073388, upload-time = "2026-03-20T14:26:14.448Z" },
{ url = "https://files.pythonhosted.org/packages/07/d7/3b86af4e63a551398763a1bbbbac91e1c0754ece7ac7157218b33a065f4c/fastar-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5569510407c0ded580cfeec99e46ebe85ce27e199e020c5c1ea6f570e302c946", size = 1025190, upload-time = "2026-03-20T14:26:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/39/07/8c50a60f03e095053306fcf57d9d99343bce0e99d5b758bf96de31aec849/fastar-0.9.0-cp314-cp314-win32.whl", hash = "sha256:3f7be0a34ffbead52ab5f4a1e445e488bf39736acb006298d3b3c5b4f2c5915e", size = 452301, upload-time = "2026-03-20T14:26:59.234Z" },
{ url = "https://files.pythonhosted.org/packages/ee/69/aa6d67b09485ba031408296d6ff844c7d83cdcb9f8fcc240422c6f83be87/fastar-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:cf7f68b98ed34ce628994c9bbd4f56cf6b4b175b3f7b8cbe35c884c8efec0a5b", size = 484948, upload-time = "2026-03-20T14:26:48.45Z" },
{ url = "https://files.pythonhosted.org/packages/20/6d/dba29d87ca929f95a5a7025c7d30720ad8478beed29fff482f29e1e8b045/fastar-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:155dae97aca4b245eabb25e23fd16bfd42a0447f9db7f7789ab1299b02d94487", size = 461170, upload-time = "2026-03-20T14:26:39.191Z" },
{ url = "https://files.pythonhosted.org/packages/96/8f/c3ea0adac50a8037987ee7f15ff94767ebb604faf6008cbd2b8efa46c372/fastar-0.9.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a63df018232623e136178953031057c7ac0dbf0acc6f0e8c1dc7dbc19e64c22f", size = 705857, upload-time = "2026-03-20T14:25:36.842Z" },
{ url = "https://files.pythonhosted.org/packages/ae/b3/e0e1aad1778065559680a73cdf982ed07b04300c2e5bf778dec8668eda6f/fastar-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6fb44f8675ef87087cb08f9bf4dfa15e818571a5f567ff692f3ea007cff867b5", size = 626210, upload-time = "2026-03-20T14:25:24.361Z" },
{ url = "https://files.pythonhosted.org/packages/94/f3/3c117335cbea26b3bc05382c27e6028278ed048d610b8de427c68f2fec84/fastar-0.9.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:81092daa991d0f095424e0e28ed589e03c81a21eeddc9b981184ddda5869bf9d", size = 864879, upload-time = "2026-03-20T14:25:00.131Z" },
{ url = "https://files.pythonhosted.org/packages/26/5d/e8d00ec3b2692d14ea111ddae25bf10e0cb60d5d79915c3d8ea393a87d5c/fastar-0.9.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e8793e2618d0d6d5a7762d6007371f57f02544364864e40e6b9d304b0f151b2", size = 759117, upload-time = "2026-03-20T14:23:54.826Z" },
{ url = "https://files.pythonhosted.org/packages/1a/61/6e080fdbc28c72dded8b6ff396035d6dc292f9b1c67b8797ac2372ca5733/fastar-0.9.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:83f7ef7056791fc95b6afa987238368c9a73ad0edcedc6bc80076f9fbd3a2a78", size = 756527, upload-time = "2026-03-20T14:24:07.494Z" },
{ url = "https://files.pythonhosted.org/packages/e8/97/2cf1a07884d171c028bd4ae5ecf7ded6f31581f79ab26711dcdad0a3d5ab/fastar-0.9.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b3a456230fcc0e560823f5d04ae8e4c867300d8ee710b14ddcdd1b316ac3dd8d", size = 921763, upload-time = "2026-03-20T14:24:20.787Z" },
{ url = "https://files.pythonhosted.org/packages/f6/e3/c1d698a45f9f5dc892ed7d64badc9c38f1e5c1667048191969c438d2b428/fastar-0.9.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a60b117ebadc46c10c87852d2158a4d6489adbfbbec37be036b4cfbeca07b449", size = 815493, upload-time = "2026-03-20T14:24:46.482Z" },
{ url = "https://files.pythonhosted.org/packages/25/38/e124a404043fba75a8cb2f755ca49e4f01e18400bb6607a5f76526e07164/fastar-0.9.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a6199b4ca0c092a7ae47f5f387492d46a0a2d82cb3b7aa0bf50d7f7d5d8d57f", size = 819166, upload-time = "2026-03-20T14:25:12.027Z" },
{ url = "https://files.pythonhosted.org/packages/85/4a/5b1ea5c8d0dbdfcec2fd1e6a243d6bb5a1c7cd55e132cc532eb8b1cbd6d9/fastar-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:34efe114caf10b4d5ea404069ff1f6cc0e55a708c7091059b0fc087f65c0a331", size = 883618, upload-time = "2026-03-20T14:24:33.552Z" },
{ url = "https://files.pythonhosted.org/packages/d3/0b/ae46e5722a67a3c2e0ff83d539b0907d6e5092f6395840c0eb6ede81c5d6/fastar-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4d44c1f8d9c5a3e4e58e6ffb77f4ca023ba9d9ddd88e7c613b3419a8feaa3db7", size = 966294, upload-time = "2026-03-20T14:25:50.024Z" },
{ url = "https://files.pythonhosted.org/packages/98/58/b161cf8711f4a50a3e57b6f89bc703c1aed282cad50434b3bc8524738b20/fastar-0.9.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d2af970a1f773965b05f1765017a417380ad080ea49590516eb25b23c039158a", size = 1033177, upload-time = "2026-03-20T14:26:02.868Z" },
{ url = "https://files.pythonhosted.org/packages/e2/76/faac7292bce9b30106a6b6a9f5ddb658fdb03abe2644688b82023c8f76b9/fastar-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:1675346d7cbdde0d21869c3b597be19b5e31a36442bdf3a48d83a49765b269dc", size = 1073620, upload-time = "2026-03-20T14:26:16.121Z" },
{ url = "https://files.pythonhosted.org/packages/b8/be/dd55ffcc302d6f0ff4aba1616a0da3edc8fcefb757869cad81de74604a35/fastar-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc440daa28591aeb4d387c171e824f179ad2ab256ce7a315472395b8d5f80392", size = 1025147, upload-time = "2026-03-20T14:26:28.767Z" },
{ url = "https://files.pythonhosted.org/packages/4b/c7/080bbb2b3c4e739fe6486fd765a09905f6c16c1068b2fcf2bb51a5e83937/fastar-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:32787880600a988d11547628034993ef948499ae4514a30509817242c4eb98b1", size = 452317, upload-time = "2026-03-20T14:27:03.243Z" },
{ url = "https://files.pythonhosted.org/packages/42/39/00553739a7e9e35f78a0c5911d181acf6b6e132337adc9bbc3575f5f6f04/fastar-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92fa18ec4958f33473259980685d29248ac44c96eed34026ad7550f93dd9ee23", size = 483994, upload-time = "2026-03-20T14:26:52.76Z" },
{ url = "https://files.pythonhosted.org/packages/4f/36/a7af08d233624515d9a0f5d41b7a01a51fd825b8c795e41800215a3200e7/fastar-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:34f646ac4f5bed3661a106ca56c1744e7146a02aacf517d47b24fd3f25dc1ff6", size = 460604, upload-time = "2026-03-20T14:26:40.771Z" },
]
[[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 = "httptools"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
]
[[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.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[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 = "markdown-it-py"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mdurl" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
]
[[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/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mdurl"
version = "0.1.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[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 = "pydantic"
version = "2.12.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/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
]
[package.optional-dependencies]
email = [
{ name = "email-validator" },
]
[[package]]
name = "pydantic-core"
version = "2.41.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" },
{ url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" },
{ url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" },
{ url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" },
{ url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" },
{ url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" },
{ url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" },
{ url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" },
{ url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" },
{ url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" },
{ url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" },
{ url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" },
{ url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" },
{ url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" },
{ url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" },
{ url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" },
{ url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" },
{ url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" },
{ url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" },
{ url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" },
{ url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" },
{ url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" },
{ url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" },
{ url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" },
]
[[package]]
name = "pydantic-extra-types"
version = "2.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/71/dba38ee2651f84f7842206adbd2233d8bbdb59fb85e9fa14232486a8c471/pydantic_extra_types-2.11.1.tar.gz", hash = "sha256:46792d2307383859e923d8fcefa82108b1a141f8a9c0198982b3832ab5ef1049", size = 172002, upload-time = "2026-03-16T08:08:03.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/c1/3226e6d7f5a4f736f38ac11a6fbb262d701889802595cdb0f53a885ac2e0/pydantic_extra_types-2.11.1-py3-none-any.whl", hash = "sha256:1722ea2bddae5628ace25f2aa685b69978ef533123e5638cfbddb999e0100ec1", size = 79526, upload-time = "2026-03-16T08:08:02.533Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.13.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" },
]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
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/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
]
[[package]]
name = "python-multipart"
version = "0.0.22"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "rich"
version = "14.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" },
]
[[package]]
name = "rich-toolkit"
version = "0.19.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "rich" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/ba/dae9e3096651042754da419a4042bc1c75e07d615f9b15066d738838e4df/rich_toolkit-0.19.7.tar.gz", hash = "sha256:133c0915872da91d4c25d85342d5ec1dfacc69b63448af1a08a0d4b4f23ef46e", size = 195877, upload-time = "2026-02-24T16:06:20.555Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/3c/c923619f6d2f5fafcc96fec0aaf9550a46cd5b6481f06e0c6b66a2a4fed0/rich_toolkit-0.19.7-py3-none-any.whl", hash = "sha256:0288e9203728c47c5a4eb60fd2f0692d9df7455a65901ab6f898437a2ba5989d", size = 32963, upload-time = "2026-02-24T16:06:22.066Z" },
]
[[package]]
name = "rignore"
version = "0.7.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/b9/1f5bd82b87e5550cd843ceb3768b4a8ef274eb63f29333cf2f29644b3d75/rignore-0.7.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8e41be9fa8f2f47239ded8920cc283699a052ac4c371f77f5ac017ebeed75732", size = 882632, upload-time = "2025-11-05T20:42:44.063Z" },
{ url = "https://files.pythonhosted.org/packages/e9/6b/07714a3efe4a8048864e8a5b7db311ba51b921e15268b17defaebf56d3db/rignore-0.7.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6dc1e171e52cefa6c20e60c05394a71165663b48bca6c7666dee4f778f2a7d90", size = 820760, upload-time = "2025-11-05T20:42:27.885Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0f/348c829ea2d8d596e856371b14b9092f8a5dfbb62674ec9b3f67e4939a9d/rignore-0.7.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ce2268837c3600f82ab8db58f5834009dc638ee17103582960da668963bebc5", size = 899044, upload-time = "2025-11-05T20:40:55.336Z" },
{ url = "https://files.pythonhosted.org/packages/f0/30/2e1841a19b4dd23878d73edd5d82e998a83d5ed9570a89675f140ca8b2ad/rignore-0.7.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:690a3e1b54bfe77e89c4bacb13f046e642f8baadafc61d68f5a726f324a76ab6", size = 874144, upload-time = "2025-11-05T20:41:10.195Z" },
{ url = "https://files.pythonhosted.org/packages/c2/bf/0ce9beb2e5f64c30e3580bef09f5829236889f01511a125f98b83169b993/rignore-0.7.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09d12ac7a0b6210c07bcd145007117ebd8abe99c8eeb383e9e4673910c2754b2", size = 1168062, upload-time = "2025-11-05T20:41:26.511Z" },
{ url = "https://files.pythonhosted.org/packages/b9/8b/571c178414eb4014969865317da8a02ce4cf5241a41676ef91a59aab24de/rignore-0.7.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a2b2b74a8c60203b08452479b90e5ce3dbe96a916214bc9eb2e5af0b6a9beb0", size = 942542, upload-time = "2025-11-05T20:41:41.838Z" },
{ url = "https://files.pythonhosted.org/packages/19/62/7a3cf601d5a45137a7e2b89d10c05b5b86499190c4b7ca5c3c47d79ee519/rignore-0.7.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc5a531ef02131e44359419a366bfac57f773ea58f5278c2cdd915f7d10ea94", size = 958739, upload-time = "2025-11-05T20:42:12.463Z" },
{ url = "https://files.pythonhosted.org/packages/5f/1f/4261f6a0d7caf2058a5cde2f5045f565ab91aa7badc972b57d19ce58b14e/rignore-0.7.6-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7a1f77d9c4cd7e76229e252614d963442686bfe12c787a49f4fe481df49e7a9", size = 984138, upload-time = "2025-11-05T20:41:56.775Z" },
{ url = "https://files.pythonhosted.org/packages/2b/bf/628dfe19c75e8ce1f45f7c248f5148b17dfa89a817f8e3552ab74c3ae812/rignore-0.7.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ead81f728682ba72b5b1c3d5846b011d3e0174da978de87c61645f2ed36659a7", size = 1079299, upload-time = "2025-11-05T21:40:16.639Z" },
{ url = "https://files.pythonhosted.org/packages/af/a5/be29c50f5c0c25c637ed32db8758fdf5b901a99e08b608971cda8afb293b/rignore-0.7.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12ffd50f520c22ffdabed8cd8bfb567d9ac165b2b854d3e679f4bcaef11a9441", size = 1139618, upload-time = "2025-11-05T21:40:34.507Z" },
{ url = "https://files.pythonhosted.org/packages/2a/40/3c46cd7ce4fa05c20b525fd60f599165e820af66e66f2c371cd50644558f/rignore-0.7.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e5a16890fbe3c894f8ca34b0fcacc2c200398d4d46ae654e03bc9b3dbf2a0a72", size = 1117626, upload-time = "2025-11-05T21:40:51.494Z" },
{ url = "https://files.pythonhosted.org/packages/8c/b9/aea926f263b8a29a23c75c2e0d8447965eb1879d3feb53cfcf84db67ed58/rignore-0.7.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3abab3bf99e8a77488ef6c7c9a799fac22224c28fe9f25cc21aa7cc2b72bfc0b", size = 1128144, upload-time = "2025-11-05T21:41:09.169Z" },
{ url = "https://files.pythonhosted.org/packages/a4/f6/0d6242f8d0df7f2ecbe91679fefc1f75e7cd2072cb4f497abaab3f0f8523/rignore-0.7.6-cp314-cp314-win32.whl", hash = "sha256:eeef421c1782953c4375aa32f06ecae470c1285c6381eee2a30d2e02a5633001", size = 646385, upload-time = "2025-11-05T21:41:55.105Z" },
{ url = "https://files.pythonhosted.org/packages/d5/38/c0dcd7b10064f084343d6af26fe9414e46e9619c5f3224b5272e8e5d9956/rignore-0.7.6-cp314-cp314-win_amd64.whl", hash = "sha256:6aeed503b3b3d5af939b21d72a82521701a4bd3b89cd761da1e7dc78621af304", size = 725738, upload-time = "2025-11-05T21:41:39.736Z" },
{ url = "https://files.pythonhosted.org/packages/d9/7a/290f868296c1ece914d565757ab363b04730a728b544beb567ceb3b2d96f/rignore-0.7.6-cp314-cp314-win_arm64.whl", hash = "sha256:104f215b60b3c984c386c3e747d6ab4376d5656478694e22c7bd2f788ddd8304", size = 656008, upload-time = "2025-11-05T21:41:29.028Z" },
{ url = "https://files.pythonhosted.org/packages/ca/d2/3c74e3cd81fe8ea08a8dcd2d755c09ac2e8ad8fe409508904557b58383d3/rignore-0.7.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bb24a5b947656dd94cb9e41c4bc8b23cec0c435b58be0d74a874f63c259549e8", size = 882835, upload-time = "2025-11-05T20:42:45.443Z" },
{ url = "https://files.pythonhosted.org/packages/77/61/a772a34b6b63154877433ac2d048364815b24c2dd308f76b212c408101a2/rignore-0.7.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b1e33c9501cefe24b70a1eafd9821acfd0ebf0b35c3a379430a14df089993e3", size = 820301, upload-time = "2025-11-05T20:42:29.226Z" },
{ url = "https://files.pythonhosted.org/packages/71/30/054880b09c0b1b61d17eeb15279d8bf729c0ba52b36c3ada52fb827cbb3c/rignore-0.7.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bec3994665a44454df86deb762061e05cd4b61e3772f5b07d1882a8a0d2748d5", size = 897611, upload-time = "2025-11-05T20:40:56.475Z" },
{ url = "https://files.pythonhosted.org/packages/1e/40/b2d1c169f833d69931bf232600eaa3c7998ba4f9a402e43a822dad2ea9f2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26cba2edfe3cff1dfa72bddf65d316ddebf182f011f2f61538705d6dbaf54986", size = 873875, upload-time = "2025-11-05T20:41:11.561Z" },
{ url = "https://files.pythonhosted.org/packages/55/59/ca5ae93d83a1a60e44b21d87deb48b177a8db1b85e82fc8a9abb24a8986d/rignore-0.7.6-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffa86694fec604c613696cb91e43892aa22e1fec5f9870e48f111c603e5ec4e9", size = 1167245, upload-time = "2025-11-05T20:41:28.29Z" },
{ url = "https://files.pythonhosted.org/packages/a5/52/cf3dce392ba2af806cba265aad6bcd9c48bb2a6cb5eee448d3319f6e505b/rignore-0.7.6-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48efe2ed95aa8104145004afb15cdfa02bea5cdde8b0344afeb0434f0d989aa2", size = 941750, upload-time = "2025-11-05T20:41:43.111Z" },
{ url = "https://files.pythonhosted.org/packages/ec/be/3f344c6218d779395e785091d05396dfd8b625f6aafbe502746fcd880af2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dcae43eb44b7f2457fef7cc87f103f9a0013017a6f4e62182c565e924948f21", size = 958896, upload-time = "2025-11-05T20:42:13.784Z" },
{ url = "https://files.pythonhosted.org/packages/c9/34/d3fa71938aed7d00dcad87f0f9bcb02ad66c85d6ffc83ba31078ce53646a/rignore-0.7.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cd649a7091c0dad2f11ef65630d30c698d505cbe8660dd395268e7c099cc99f", size = 983992, upload-time = "2025-11-05T20:41:58.022Z" },
{ url = "https://files.pythonhosted.org/packages/24/a4/52a697158e9920705bdbd0748d59fa63e0f3233fb92e9df9a71afbead6ca/rignore-0.7.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42de84b0289d478d30ceb7ae59023f7b0527786a9a5b490830e080f0e4ea5aeb", size = 1078181, upload-time = "2025-11-05T21:40:18.151Z" },
{ url = "https://files.pythonhosted.org/packages/ac/65/aa76dbcdabf3787a6f0fd61b5cc8ed1e88580590556d6c0207960d2384bb/rignore-0.7.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:875a617e57b53b4acbc5a91de418233849711c02e29cc1f4f9febb2f928af013", size = 1139232, upload-time = "2025-11-05T21:40:35.966Z" },
{ url = "https://files.pythonhosted.org/packages/08/44/31b31a49b3233c6842acc1c0731aa1e7fb322a7170612acf30327f700b44/rignore-0.7.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8703998902771e96e49968105207719f22926e4431b108450f3f430b4e268b7c", size = 1117349, upload-time = "2025-11-05T21:40:53.013Z" },
{ url = "https://files.pythonhosted.org/packages/e9/ae/1b199a2302c19c658cf74e5ee1427605234e8c91787cfba0015f2ace145b/rignore-0.7.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:602ef33f3e1b04c1e9a10a3c03f8bc3cef2d2383dcc250d309be42b49923cabc", size = 1127702, upload-time = "2025-11-05T21:41:10.881Z" },
{ url = "https://files.pythonhosted.org/packages/fc/d3/18210222b37e87e36357f7b300b7d98c6dd62b133771e71ae27acba83a4f/rignore-0.7.6-cp314-cp314t-win32.whl", hash = "sha256:c1d8f117f7da0a4a96a8daef3da75bc090e3792d30b8b12cfadc240c631353f9", size = 647033, upload-time = "2025-11-05T21:42:00.095Z" },
{ url = "https://files.pythonhosted.org/packages/3e/87/033eebfbee3ec7d92b3bb1717d8f68c88e6fc7de54537040f3b3a405726f/rignore-0.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:ca36e59408bec81de75d307c568c2d0d410fb880b1769be43611472c61e85c96", size = 725647, upload-time = "2025-11-05T21:41:44.449Z" },
{ url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" },
]
[[package]]
name = "sentry-sdk"
version = "2.56.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/df/5008954f5466085966468612a7d1638487596ee6d2fd7fb51783a85351bf/sentry_sdk-2.56.0.tar.gz", hash = "sha256:fdab72030b69625665b2eeb9738bdde748ad254e8073085a0ce95382678e8168", size = 426820, upload-time = "2026-03-24T09:56:36.575Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/1a/b3a3e9f6520493fed7997af4d2de7965d71549c62f994a8fd15f2ecd519e/sentry_sdk-2.56.0-py2.py3-none-any.whl", hash = "sha256:5afafb744ceb91d22f4cc650c6bd048ac6af5f7412dcc6c59305a2e36f4dbc02", size = 451568, upload-time = "2026-03-24T09:56:34.807Z" },
]
[[package]]
name = "shellingham"
version = "1.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" },
]
[[package]]
name = "starlette"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/69/17425771797c36cded50b7fe44e850315d039f28b15901ab44839e70b593/starlette-1.0.0.tar.gz", hash = "sha256:6a4beaf1f81bb472fd19ea9b918b50dc3a77a6f2e190a12954b25e6ed5eea149", size = 2655289, upload-time = "2026-03-22T18:29:46.779Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/c9/584bc9651441b4ba60cc4d557d8a547b5aff901af35bda3a4ee30c819b82/starlette-1.0.0-py3-none-any.whl", hash = "sha256:d3ec55e0bb321692d275455ddfd3df75fff145d009685eb40dc91fc66b03d38b", size = 72651, upload-time = "2026-03-22T18:29:45.111Z" },
]
[[package]]
name = "task-flow"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "fastapi", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [{ name = "fastapi", extras = ["standard"], specifier = ">=0.135.2" }]
[package.metadata.requires-dev]
dev = [{ name = "pytest", specifier = ">=9.0.2" }]
[[package]]
name = "typer"
version = "0.24.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "click" },
{ name = "rich" },
{ name = "shellingham" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f5/24/cb09efec5cc954f7f9b930bf8279447d24618bb6758d4f6adf2574c41780/typer-0.24.1.tar.gz", hash = "sha256:e39b4732d65fbdcde189ae76cf7cd48aeae72919dea1fdfc16593be016256b45", size = 118613, upload-time = "2026-02-21T16:54:40.609Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/91/48db081e7a63bb37284f9fbcefda7c44c277b18b0e13fbc36ea2335b71e6/typer-0.24.1-py3-none-any.whl", hash = "sha256:112c1f0ce578bfb4cab9ffdabc68f031416ebcc216536611ba21f04e9aa84c9e", size = 56085, upload-time = "2026-02-21T16:54:41.616Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
]
[[package]]
name = "urllib3"
version = "2.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
]
[[package]]
name = "uvicorn"
version = "0.42.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e3/ad/4a96c425be6fb67e0621e62d86c402b4a17ab2be7f7c055d9bd2f638b9e2/uvicorn-0.42.0.tar.gz", hash = "sha256:9b1f190ce15a2dd22e7758651d9b6d12df09a13d51ba5bf4fc33c383a48e1775", size = 85393, upload-time = "2026-03-16T06:19:50.077Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/89/f8827ccff89c1586027a105e5630ff6139a64da2515e24dafe860bd9ae4d/uvicorn-0.42.0-py3-none-any.whl", hash = "sha256:96c30f5c7abe6f74ae8900a70e92b85ad6613b745d4879eb9b16ccad15645359", size = 68830, upload-time = "2026-03-16T06:19:48.325Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchfiles"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]