feat(taskflow): add core task API, storage persistence, csv export, stats page, and test coverage
This commit is contained in:
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal 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
1
.python-version
Normal file
@@ -0,0 +1 @@
|
||||
3.14
|
||||
30
Dockerfile
Normal file
30
Dockerfile
Normal 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
432
PROMTS.md
Normal 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
314
README.md
Normal 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
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
6
app/api/__init__.py
Normal file
6
app/api/__init__.py
Normal 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
22
app/api/dto/__init__.py
Normal 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
142
app/api/dto/tasks.py
Normal 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
18
app/api/health.py
Normal 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
80
app/api/stats.py
Normal 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
338
app/api/tasks.py
Normal 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
8
app/domain/__init__.py
Normal 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
60
app/domain/tasks.py
Normal 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
22
app/main.py
Normal 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
15
app/storage/__init__.py
Normal 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
16
app/storage/factory.py
Normal 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
58
app/storage/models.py
Normal 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
167
app/storage/repository.py
Normal 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
14
pyproject.toml
Normal 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
268
templates/stats.html
Normal 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
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
216
tests/test_api_stats.py
Normal file
216
tests/test_api_stats.py
Normal 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 "<script>alert(1)</script>" 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
502
tests/test_api_tasks.py
Normal 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")
|
||||
|
||||
96
tests/test_domain_tasks.py
Normal file
96
tests/test_domain_tasks.py
Normal 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
45
tests/test_health.py
Normal 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
|
||||
83
tests/test_repository_atomicity.py
Normal file
83
tests/test_repository_atomicity.py
Normal 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()
|
||||
|
||||
205
tests/test_repository_crud.py
Normal file
205
tests/test_repository_crud.py
Normal 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]
|
||||
|
||||
146
tests/test_repository_persistence.py
Normal file
146
tests/test_repository_persistence.py
Normal 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]
|
||||
|
||||
102
tests/test_repository_recovery.py
Normal file
102
tests/test_repository_recovery.py
Normal 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
766
uv.lock
generated
Normal 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" },
|
||||
]
|
||||
Reference in New Issue
Block a user