Compare commits

...

19 Commits

Author SHA1 Message Date
Сергей Ванюшкин bde9581090 docker tests finished ok 2024-01-29 22:22:36 +03:00
Сергей Ванюшкин c27858e4fb just for sync 2024-01-29 17:11:39 +00:00
Сергей Ванюшкин 479a997844 just for sync 2024-01-29 09:34:11 +00:00
Сергей Ванюшкин 08f3297297 docker tests finished ok 2024-01-29 07:29:02 +03:00
Сергей Ванюшкин b5da5736e9 sync 2024-01-29 04:35:37 +03:00
Сергей Ванюшкин 1b5182b41a sync 2024-01-29 02:22:50 +03:00
Сергей Ванюшкин bab8008ec8 sync 2024-01-29 02:19:02 +03:00
Сергей Ванюшкин cae407a5f4 testing readme 2024-01-28 16:36:32 +03:00
Сергей Ванюшкин dce3841d5a testing readme 2024-01-28 16:34:26 +03:00
Сергей Ванюшкин e2428d7cdc testing readme 2024-01-28 16:32:22 +03:00
Сергей Ванюшкин 51b5b909c9 testing readme 2024-01-28 16:29:54 +03:00
Сергей Ванюшкин b282ceebe7 testing readme 2024-01-28 16:27:20 +03:00
Сергей Ванюшкин 5ced7acef8 docker and docker tests 2024-01-28 16:22:24 +03:00
Сергей Ванюшкин 8f48352600 just for sync 2024-01-27 16:28:55 +00:00
Сергей Ванюшкин d6f1347fab client fixture and Menu&Submenu base test 2024-01-27 12:48:20 +00:00
Сергей Ванюшкин b474e21f0f sync 2024-01-27 14:43:46 +03:00
Сергей Ванюшкин b20ff8bceb sync 2024-01-27 08:55:09 +03:00
Сергей Ванюшкин f09b5b57b2 just for sync 2024-01-26 09:52:35 +00:00
Сергей Ванюшкин 0ae3293730 fixtures&easytest 2024-01-26 00:23:38 +03:00
19 changed files with 1068 additions and 58 deletions

15
Dockerfile Normal file
View File

@ -0,0 +1,15 @@
FROM python:3.10-slim
RUN mkdir /fastfood
WORKDIR /fastfood
COPY . .
RUN pip install poetry
RUN poetry config virtualenvs.create false
RUN poetry install
RUN chmod a+x scripts/*.sh

View File

@ -2,9 +2,10 @@
Fastapi веб приложение реализующее api для общепита.
## Описание
Данный проект, это результат выполнения практического домашнего задания интенсива от YLAB Development. Проект реализован на фреймворке fastapi, с использованием sqlalchemy. В качестве базы данных используется postgresql.
Данный проект, это результат выполнения практических домашних заданий интенсива от YLAB Development. Проект реализован на фреймворке fastapi, с использованием sqlalchemy. В качестве базы данных используется postgresql.
### Техническое задание
## Техническое задание
### Спринт 1 - Создание API
Написать проект на FastAPI с использованием PostgreSQL в качестве БД. В проекте следует реализовать REST API по работе с меню ресторана, все CRUD операции. Для проверки задания, к презентаций будет приложена Postman коллекция с тестами. Задание выполнено, если все тесты проходят успешно.
Даны 3 сущности: Меню, Подменю, Блюдо.
@ -25,18 +26,85 @@ Fastapi веб приложение реализующее api для общеп
В папке ./postman_scripts находятся фалы тестов Postman, для тестирования функционала проекта.
### Спринт 2 - Docker && pytest
В этом домашнем задании надо написать тесты для ранее разработанных ендпоинтов вашего API после Вебинара №1.
Обернуть программные компоненты в контейнеры. Контейнеры должны запускаться по одной команде “docker-compose up -d” или той которая описана вами в readme.md.
Образы для Docker:
(API) python:3.10-slim
(DB) postgres:15.1-alpine
1.Написать CRUD тесты для ранее разработанного API с помощью библиотеки pytest
2.Подготовить отдельный контейнер для запуска тестов. Команду для запуска указать в README.md
3.* Реализовать вывод количества подменю и блюд для Меню через один (сложный) ORM запрос.
4.** Реализовать тестовый сценарий «Проверка кол-ва блюд и подменю в меню» из Postman с помощью pytest
Если FastAPI синхронное - тесты синхронные, Если асинхронное - тесты асинхронные
*Оборачиваем приложение в докер.
**CRUD create/update/retrieve/delete.
<a href="https://drive.google.com/drive/folders/13t6fsMO0B6Ls0qYl-uVgAHWOyhFTFv4Z?usp=sharing">Дополнительные материалы</a>
## Возможности
### Спринт 1
В проекте реализованы 3 сущности: Menu, SubMenu и Dish. Для каждого них реализованы 4 метода http запросов: GET, POST, PATCH и DELETE c помощью которых можно управлять данными.
Для Menu доступен метод GET возвращающий все его SubMenu. Аналогично для SubMenu реализован метод для возврата всех Dish.
### Спринт 2
- 1й пункт ТЗ
Тесты реализованы в виде 2х классов
`TastBaseCrud` включает 3 подкласса `Menu`, `Submenu`, `Dish` которые реализуют интерфейсы взаимодействия с endpoint'ами реализованных на предыдущем спринте сущностей. Каждый подкласс реализует методы GET(получение всех сущностей), Get(получение конкректной сущности), Post(создание), Patch(обновление), Delete(удаления). Так же в классе реализованы 3 тестовых функции, которые осуществляют тестирование соответствующих endpoint'ов
`TestContinuity` реализует последовательность сценария «Проверка кол-ва блюд и подменю в меню» из Postman
- 2й пункт ТЗ
Реализованы 3 контейнера(db, app, tests). В db написан блок "проверки здоровья", от которого зависят контейнеры app и test, который гарантирует, что зависимые контейнеры не будут запущены о полной готовности db.
- 3й пункт ТЗ
см. функцию `get_menu_item` на 28 строке в файле
<base_dir>/fastfood/crud/menu.py
- 4й пункт ТЗ
см. класс `TestContinuity` в файле
<base_dir>/tests/test_api.py
## Зависимости
Для локальной установки
- postgresql Для работы сервиса необходима установленная СУБД. Должна быть создана база данных и пользователь с правами на нее.
- poetry - Система управления зависимостями в Python.
Остальное добавится автоматически на этапе установки.
Для запуска в контейнере
- docker
- docker-compose
## Установка
### Docker
Для запуска необходимы установленные приложения docker и docker-compose
Клонируйте репозиторий
> `$ git clone https://git.pi3c.ru/pi3c/fastfood.git`
Перейдите в каталог
> `$ cd fastfood`
Создадим файл .env из шаблона
>`$ cp ./example.env ./.env`
Для теста изменять файл .env не требуется.
Однако Вы можете изменить имя пользователя, пароль и имя базы данных по своему усмотрению. При таких изменениях, нужно будет отредактировать
файл `db_prepare.sql` в папке `scripts/`, так чтобы sql команда приняла вид:
`CREATE DATABASE <db_name>_test WITH OWNER <db_user>;`
где <db_name> и <db_user> соответвтовали POSTGRES_DB и POSTGRES_USER в файле `.env`
Создайте и запустите образы
> `$ docker-compose up -d --build`
После успешного запуска образов документация по API будет доступна по адресу <a href="http://localhost:8000/docs">http://localhost:8000</a>
Для запуска тестов pytest поднимаем контейнер tests
> `$ docker-compose up tests`
### Linux
Установите и настройте postgresql согласно офф. документации. Создайте пользователя и бд.
@ -56,7 +124,7 @@ Fastapi веб приложение реализующее api для общеп
Файл example.env является образцом файла .env, который необходимо создать перед запуском проекта.
В нем указанны переменные необходимые для подключения к БД.
Созданим файл .env
Создадим файл .env
>`$ cp ./example.env ./.env`
@ -85,6 +153,7 @@ Fastapi веб приложение реализующее api для общеп
## TODO
- Написать тесты для кривых данных
- Добавить миграции
- Провести рефакторинг, много дублирующего кода
- Много чего другого :)

44
docker-compose.yml Normal file
View File

@ -0,0 +1,44 @@
version: "3.8"
services:
db:
image: postgres:15.1-alpine
env_file:
- .env
container_name: pgdatabase
ports:
- 6432:5432
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d postgres"]
interval: 10s
timeout: 5s
retries: 5
volumes:
- ./scripts/db_prepare.sql:/docker-entrypoint-initdb.d/db_prepare.sql
app:
build:
context: .
container_name: fastfood_app
env_file:
- .env
command: ["/fastfood/scripts/migrate_and_run.sh"]
ports:
- 8000:8000
depends_on:
db:
condition: service_healthy
restart: always
tests:
build:
context: .
container_name: tests
env_file:
- .env
depends_on:
db:
condition: service_healthy
app:
condition: service_started
command: ["/fastfood/scripts/testing.sh"]

View File

@ -1,5 +1,5 @@
DB_HOST=localhost
DB_HOST=db
DB_PORT=5432
DB_USER=postgres
DB_PASS=postgres
DB_NAME=postgres
POSTGRES_USER=postges
POSTGRES_PASSWORD=postgres
POSTGRES_DB=fastfood_db

View File

@ -2,11 +2,11 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
DB_HOST: str = "localhost"
DB_HOST: str = "db"
DB_PORT: int = 5432
DB_USER: str = "postrges"
DB_PASS: str = "postgres"
DB_NAME: str = "postgres"
POSTGRES_DB: str = "fastfod_db"
POSTGRES_PASSWORD: str = "postgres"
POSTGRES_USER: str = "postgres"
@property
def DATABASE_URL_asyncpg(self):
@ -14,8 +14,20 @@ class Settings(BaseSettings):
Возвращает строку подключения к БД необходимую для SQLAlchemy
"""
return (
f"postgresql+asyncpg://{self.DB_USER}:{self.DB_PASS}"
f"@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}"
"postgresql+asyncpg://"
f"{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.DB_HOST}:{self.DB_PORT}/{self.POSTGRES_DB}"
)
@property
def TESTDATABASE_URL_asyncpg(self):
"""
Возвращает строку подключения к БД необходимую для SQLAlchemy
"""
return (
"postgresql+asyncpg://"
f"{self.POSTGRES_USER}:{self.POSTGRES_PASSWORD}"
f"@{self.DB_HOST}:{self.DB_PORT}/{self.POSTGRES_DB}_test"
)
model_config = SettingsConfigDict(env_file=".env")

View File

@ -1,7 +1,8 @@
from uuid import UUID
from sqlalchemy import delete, func, select, update
from sqlalchemy import delete, distinct, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from fastfood import models, schemas
@ -11,8 +12,8 @@ class MenuCrud:
async def get_menus(session: AsyncSession):
async with session:
query = select(models.Menu)
result = await session.execute(query)
return result.scalars().all()
menus = await session.execute(query)
return menus
@staticmethod
async def create_menu_item(menu: schemas.MenuBase, session: AsyncSession):
@ -26,26 +27,26 @@ class MenuCrud:
@staticmethod
async def get_menu_item(menu_id: UUID, session: AsyncSession):
async with session:
query = select(models.Menu).where(models.Menu.id == menu_id)
m = aliased(models.Menu)
s = aliased(models.SubMenu)
d = aliased(models.Dish)
query = (
select(
m,
func.count(distinct(s.id)).label("submenus_count"),
func.count(distinct(d.id)).label("dishes_count"),
)
.join(s, s.parent_menu == m.id, isouter=True)
.join(d, d.parent_submenu == s.id, isouter=True)
.group_by(m.id)
.where(m.id == menu_id)
)
menu = await session.execute(query)
menu = menu.scalars().one_or_none()
if menu is None:
return None
submenu_query = select(
func.count(models.SubMenu.id).label("counter")
).filter(models.SubMenu.parent_menu == menu_id)
counter = await session.execute(submenu_query)
dish_query = (
select(func.count(models.Dish.id))
.join(models.SubMenu)
.filter(models.Dish.parent_submenu == models.SubMenu.id)
.filter(models.SubMenu.parent_menu == menu_id)
)
dishes = await session.execute(dish_query)
menu.submenus_count = counter.scalars().one_or_none()
menu.dishes_count = dishes.scalars().one_or_none()
return menu
return menu
@staticmethod
async def update_menu_item(
@ -63,7 +64,7 @@ class MenuCrud:
await session.commit()
qr = select(models.Menu).where(models.Menu.id == menu_id)
updated_menu = await session.execute(qr)
return updated_menu.scalars().one()
return updated_menu
@staticmethod
async def delete_menu_item(menu_id: UUID, session: AsyncSession):

View File

@ -1,7 +1,8 @@
from uuid import UUID
from sqlalchemy import delete, func, select, update
from sqlalchemy import delete, distinct, func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import aliased
from fastfood import models, schemas
@ -10,9 +11,11 @@ class SubMenuCrud:
@staticmethod
async def get_submenus(menu_id: UUID, session: AsyncSession):
async with session:
query = select(models.SubMenu).where(models.SubMenu.parent_menu == menu_id)
query = select(models.SubMenu).where(
models.SubMenu.parent_menu == menu_id,
)
submenus = await session.execute(query)
return submenus.scalars().all()
return submenus
@staticmethod
async def create_submenu_item(
@ -24,9 +27,9 @@ class SubMenuCrud:
new_submenu = models.SubMenu(**submenu.model_dump())
new_submenu.parent_menu = menu_id
session.add(new_submenu)
await session.flush()
await session.commit()
return new_submenu
await session.refresh(new_submenu)
return new_submenu
@staticmethod
async def get_submenu_item(
@ -35,21 +38,19 @@ class SubMenuCrud:
session: AsyncSession,
):
async with session:
query = select(models.SubMenu).where(models.SubMenu.id == submenu_id)
s = aliased(models.SubMenu)
d = aliased(models.Dish)
query = (
select(s, func.count(distinct(d.id)))
.join(d, s.id == d.parent_submenu, isouter=True)
.group_by(s.id)
.where(s.id == submenu_id)
)
submenu = await session.execute(query)
submenu = submenu.scalars().one_or_none()
if submenu is None:
return None
dish_query = (
select(func.count(models.Dish.id))
.join(models.SubMenu)
.filter(models.Dish.parent_submenu == models.SubMenu.id)
)
dishes = await session.execute(dish_query)
submenu.dishes_count = dishes.scalars().one_or_none()
return submenu
return submenu
@staticmethod
async def update_submenu_item(
@ -67,11 +68,13 @@ class SubMenuCrud:
await session.commit()
qr = select(models.SubMenu).where(models.SubMenu.id == submenu_id)
updated_submenu = await session.execute(qr)
return updated_submenu.scalars().one()
return updated_submenu
@staticmethod
async def delete_submenu_item(submenu_id: UUID, session: AsyncSession):
async with session:
query = delete(models.SubMenu).where(models.SubMenu.id == submenu_id)
query = delete(models.SubMenu).where(
models.SubMenu.id == submenu_id,
)
await session.execute(query)
await session.commit()

View File

@ -1,9 +1,11 @@
import uuid
from copy import deepcopy
from typing import Annotated, List, Optional
from sqlalchemy import ForeignKey
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from sqlalchemy.util import hybridproperty
uuidpk = Annotated[
uuid.UUID,
@ -21,6 +23,17 @@ class Base(DeclarativeBase):
title: Mapped[str_25]
description: Mapped[Optional[str]]
def __eq__(self, other):
classes_match = isinstance(other, self.__class__)
a, b = deepcopy(self.__dict__), deepcopy(other.__dict__)
a.pop("_sa_instance_state", None)
b.pop("_sa_instance_state", None)
attrs_match = a == b
return classes_match and attrs_match
def __ne__(self, other):
return not self.__eq__(other)
class Menu(Base):
__tablename__ = "menu"
@ -28,10 +41,21 @@ class Menu(Base):
submenus: Mapped[List["SubMenu"]] = relationship(
"SubMenu",
backref="menu",
lazy="dynamic",
lazy="selectin",
cascade="all, delete",
)
@hybridproperty
def submenus_count(self):
return len(self.submenus)
@hybridproperty
def dishes_count(self):
counter = 0
for sub in self.submenus:
counter += len(sub.dishes)
return counter
class SubMenu(Base):
__tablename__ = "submenu"
@ -42,10 +66,14 @@ class SubMenu(Base):
dishes: Mapped[List["Dish"]] = relationship(
"Dish",
backref="submenu",
lazy="dynamic",
lazy="selectin",
cascade="all, delete",
)
@hybridproperty
def dishes_count(self):
return len(self.dishes)
class Dish(Base):
__tablename__ = "dish"

View File

@ -17,7 +17,7 @@ router = APIRouter(
@router.get("/", response_model=Optional[List[schemas.Menu]])
async def get_menus(session: AsyncSession = Depends(get_async_session)):
result = await crud.get_menus(session=session)
return result
return result.scalars().all()
@router.post("/", status_code=201, response_model=schemas.Menu)
@ -38,6 +38,7 @@ async def get_menu(
session: AsyncSession = Depends(get_async_session),
):
result = await crud.get_menu_item(menu_id=menu_id, session=session)
if not result:
raise HTTPException(status_code=404, detail="menu not found")
return result
@ -54,7 +55,7 @@ async def update_menu(
menu=menu,
session=session,
)
return result
return result.scalars().one()
@router.delete("/{menu_id}")

View File

@ -18,7 +18,7 @@ async def get_submenus(
menu_id: UUID, session: AsyncSession = Depends(get_async_session)
):
result = await crud.get_submenus(menu_id=menu_id, session=session)
return result
return result.scalars().all()
@router.post("/", status_code=201)
@ -66,7 +66,7 @@ async def update_submenu(
submenu=submenu,
session=session,
)
return result
return result.scalars().one()
@router.delete("/{submenu_id}")

View File

@ -12,8 +12,11 @@ def run_app():
"""
uvicorn.run(
app="fastfood.app:create_app",
host="0.0.0.0",
port=8000,
reload=True,
factory=True,
workers=1,
)

268
poetry.lock generated
View File

@ -1,5 +1,24 @@
# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand.
[[package]]
name = "alembic"
version = "1.13.1"
description = "A database migration tool for SQLAlchemy."
optional = false
python-versions = ">=3.8"
files = [
{file = "alembic-1.13.1-py3-none-any.whl", hash = "sha256:2edcc97bed0bd3272611ce3a98d98279e9c209e7186e43e75bbb1b2bdfdbcc43"},
{file = "alembic-1.13.1.tar.gz", hash = "sha256:4932c8558bf68f2ee92b9bbcb8218671c627064d5b08939437af6d77dc05e595"},
]
[package.dependencies]
Mako = "*"
SQLAlchemy = ">=1.3.0"
typing-extensions = ">=4"
[package.extras]
tz = ["backports.zoneinfo"]
[[package]]
name = "annotated-types"
version = "0.6.0"
@ -101,6 +120,17 @@ async-timeout = {version = ">=4.0.3", markers = "python_version < \"3.12.0\""}
docs = ["Sphinx (>=5.3.0,<5.4.0)", "sphinx-rtd-theme (>=1.2.2)", "sphinxcontrib-asyncio (>=0.3.0,<0.4.0)"]
test = ["flake8 (>=6.1,<7.0)", "uvloop (>=0.15.3)"]
[[package]]
name = "certifi"
version = "2023.11.17"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.6"
files = [
{file = "certifi-2023.11.17-py3-none-any.whl", hash = "sha256:e036ab49d5b79556f99cfc2d9320b34cfbe5be05c5871b51de9329f0603b0474"},
{file = "certifi-2023.11.17.tar.gz", hash = "sha256:9b469f3a900bf28dc19b8cfbf8019bf47f7fdd1a65a1d4ffb98fc14166beb4d1"},
]
[[package]]
name = "click"
version = "8.1.7"
@ -126,6 +156,73 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "coverage"
version = "7.4.1"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "coverage-7.4.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:077d366e724f24fc02dbfe9d946534357fda71af9764ff99d73c3c596001bbd7"},
{file = "coverage-7.4.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0193657651f5399d433c92f8ae264aff31fc1d066deee4b831549526433f3f61"},
{file = "coverage-7.4.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d17bbc946f52ca67adf72a5ee783cd7cd3477f8f8796f59b4974a9b59cacc9ee"},
{file = "coverage-7.4.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a3277f5fa7483c927fe3a7b017b39351610265308f5267ac6d4c2b64cc1d8d25"},
{file = "coverage-7.4.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6dceb61d40cbfcf45f51e59933c784a50846dc03211054bd76b421a713dcdf19"},
{file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6008adeca04a445ea6ef31b2cbaf1d01d02986047606f7da266629afee982630"},
{file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:c61f66d93d712f6e03369b6a7769233bfda880b12f417eefdd4f16d1deb2fc4c"},
{file = "coverage-7.4.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b9bb62fac84d5f2ff523304e59e5c439955fb3b7f44e3d7b2085184db74d733b"},
{file = "coverage-7.4.1-cp310-cp310-win32.whl", hash = "sha256:f86f368e1c7ce897bf2457b9eb61169a44e2ef797099fb5728482b8d69f3f016"},
{file = "coverage-7.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:869b5046d41abfea3e381dd143407b0d29b8282a904a19cb908fa24d090cc018"},
{file = "coverage-7.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b8ffb498a83d7e0305968289441914154fb0ef5d8b3157df02a90c6695978295"},
{file = "coverage-7.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3cacfaefe6089d477264001f90f55b7881ba615953414999c46cc9713ff93c8c"},
{file = "coverage-7.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d6850e6e36e332d5511a48a251790ddc545e16e8beaf046c03985c69ccb2676"},
{file = "coverage-7.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18e961aa13b6d47f758cc5879383d27b5b3f3dcd9ce8cdbfdc2571fe86feb4dd"},
{file = "coverage-7.4.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dfd1e1b9f0898817babf840b77ce9fe655ecbe8b1b327983df485b30df8cc011"},
{file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:6b00e21f86598b6330f0019b40fb397e705135040dbedc2ca9a93c7441178e74"},
{file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:536d609c6963c50055bab766d9951b6c394759190d03311f3e9fcf194ca909e1"},
{file = "coverage-7.4.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7ac8f8eb153724f84885a1374999b7e45734bf93a87d8df1e7ce2146860edef6"},
{file = "coverage-7.4.1-cp311-cp311-win32.whl", hash = "sha256:f3771b23bb3675a06f5d885c3630b1d01ea6cac9e84a01aaf5508706dba546c5"},
{file = "coverage-7.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:9d2f9d4cc2a53b38cabc2d6d80f7f9b7e3da26b2f53d48f05876fef7956b6968"},
{file = "coverage-7.4.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:f68ef3660677e6624c8cace943e4765545f8191313a07288a53d3da188bd8581"},
{file = "coverage-7.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23b27b8a698e749b61809fb637eb98ebf0e505710ec46a8aa6f1be7dc0dc43a6"},
{file = "coverage-7.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e3424c554391dc9ef4a92ad28665756566a28fecf47308f91841f6c49288e66"},
{file = "coverage-7.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0860a348bf7004c812c8368d1fc7f77fe8e4c095d661a579196a9533778e156"},
{file = "coverage-7.4.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fe558371c1bdf3b8fa03e097c523fb9645b8730399c14fe7721ee9c9e2a545d3"},
{file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3468cc8720402af37b6c6e7e2a9cdb9f6c16c728638a2ebc768ba1ef6f26c3a1"},
{file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:02f2edb575d62172aa28fe00efe821ae31f25dc3d589055b3fb64d51e52e4ab1"},
{file = "coverage-7.4.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ca6e61dc52f601d1d224526360cdeab0d0712ec104a2ce6cc5ccef6ed9a233bc"},
{file = "coverage-7.4.1-cp312-cp312-win32.whl", hash = "sha256:ca7b26a5e456a843b9b6683eada193fc1f65c761b3a473941efe5a291f604c74"},
{file = "coverage-7.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:85ccc5fa54c2ed64bd91ed3b4a627b9cce04646a659512a051fa82a92c04a448"},
{file = "coverage-7.4.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:8bdb0285a0202888d19ec6b6d23d5990410decb932b709f2b0dfe216d031d218"},
{file = "coverage-7.4.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:918440dea04521f499721c039863ef95433314b1db00ff826a02580c1f503e45"},
{file = "coverage-7.4.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:379d4c7abad5afbe9d88cc31ea8ca262296480a86af945b08214eb1a556a3e4d"},
{file = "coverage-7.4.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b094116f0b6155e36a304ff912f89bbb5067157aff5f94060ff20bbabdc8da06"},
{file = "coverage-7.4.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2f5968608b1fe2a1d00d01ad1017ee27efd99b3437e08b83ded9b7af3f6f766"},
{file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:10e88e7f41e6197ea0429ae18f21ff521d4f4490aa33048f6c6f94c6045a6a75"},
{file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a4a3907011d39dbc3e37bdc5df0a8c93853c369039b59efa33a7b6669de04c60"},
{file = "coverage-7.4.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6d224f0c4c9c98290a6990259073f496fcec1b5cc613eecbd22786d398ded3ad"},
{file = "coverage-7.4.1-cp38-cp38-win32.whl", hash = "sha256:23f5881362dcb0e1a92b84b3c2809bdc90db892332daab81ad8f642d8ed55042"},
{file = "coverage-7.4.1-cp38-cp38-win_amd64.whl", hash = "sha256:a07f61fc452c43cd5328b392e52555f7d1952400a1ad09086c4a8addccbd138d"},
{file = "coverage-7.4.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8e738a492b6221f8dcf281b67129510835461132b03024830ac0e554311a5c54"},
{file = "coverage-7.4.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:46342fed0fff72efcda77040b14728049200cbba1279e0bf1188f1f2078c1d70"},
{file = "coverage-7.4.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9641e21670c68c7e57d2053ddf6c443e4f0a6e18e547e86af3fad0795414a628"},
{file = "coverage-7.4.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aeb2c2688ed93b027eb0d26aa188ada34acb22dceea256d76390eea135083950"},
{file = "coverage-7.4.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d12c923757de24e4e2110cf8832d83a886a4cf215c6e61ed506006872b43a6d1"},
{file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0491275c3b9971cdbd28a4595c2cb5838f08036bca31765bad5e17edf900b2c7"},
{file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:8dfc5e195bbef80aabd81596ef52a1277ee7143fe419efc3c4d8ba2754671756"},
{file = "coverage-7.4.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:1a78b656a4d12b0490ca72651fe4d9f5e07e3c6461063a9b6265ee45eb2bdd35"},
{file = "coverage-7.4.1-cp39-cp39-win32.whl", hash = "sha256:f90515974b39f4dea2f27c0959688621b46d96d5a626cf9c53dbc653a895c05c"},
{file = "coverage-7.4.1-cp39-cp39-win_amd64.whl", hash = "sha256:64e723ca82a84053dd7bfcc986bdb34af8d9da83c521c19d6b472bc6880e191a"},
{file = "coverage-7.4.1-pp38.pp39.pp310-none-any.whl", hash = "sha256:32a8d985462e37cfdab611a6f95b09d7c091d07668fdc26e47a725ee575fe166"},
{file = "coverage-7.4.1.tar.gz", hash = "sha256:1ed4b95480952b1a26d863e546fa5094564aa0065e1e5f0d4d0041f293251d04"},
]
[package.dependencies]
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
[package.extras]
toml = ["tomli"]
[[package]]
name = "dnspython"
version = "2.5.0"
@ -276,6 +373,51 @@ files = [
{file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"},
]
[[package]]
name = "httpcore"
version = "1.0.2"
description = "A minimal low-level HTTP client."
optional = false
python-versions = ">=3.8"
files = [
{file = "httpcore-1.0.2-py3-none-any.whl", hash = "sha256:096cc05bca73b8e459a1fc3dcf585148f63e534eae4339559c9b8a8d6399acc7"},
{file = "httpcore-1.0.2.tar.gz", hash = "sha256:9fc092e4799b26174648e54b74ed5f683132a464e95643b226e00c2ed2fa6535"},
]
[package.dependencies]
certifi = "*"
h11 = ">=0.13,<0.15"
[package.extras]
asyncio = ["anyio (>=4.0,<5.0)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
trio = ["trio (>=0.22.0,<0.23.0)"]
[[package]]
name = "httpx"
version = "0.26.0"
description = "The next generation HTTP client."
optional = false
python-versions = ">=3.8"
files = [
{file = "httpx-0.26.0-py3-none-any.whl", hash = "sha256:8915f5a3627c4d47b73e8202457cb28f1266982d1159bd5779d86a80c0eab1cd"},
{file = "httpx-0.26.0.tar.gz", hash = "sha256:451b55c30d5185ea6b23c2c793abf9bb237d2a7dfb901ced6ff69ad37ec1dfaf"},
]
[package.dependencies]
anyio = "*"
certifi = "*"
httpcore = "==1.*"
idna = "*"
sniffio = "*"
[package.extras]
brotli = ["brotli", "brotlicffi"]
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (==1.*)"]
[[package]]
name = "idna"
version = "3.6"
@ -298,6 +440,94 @@ files = [
{file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"},
]
[[package]]
name = "mako"
version = "1.3.0"
description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
optional = false
python-versions = ">=3.8"
files = [
{file = "Mako-1.3.0-py3-none-any.whl", hash = "sha256:57d4e997349f1a92035aa25c17ace371a4213f2ca42f99bee9a602500cfd54d9"},
{file = "Mako-1.3.0.tar.gz", hash = "sha256:e3a9d388fd00e87043edbe8792f45880ac0114e9c4adc69f6e9bfb2c55e3b11b"},
]
[package.dependencies]
MarkupSafe = ">=0.9.2"
[package.extras]
babel = ["Babel"]
lingua = ["lingua"]
testing = ["pytest"]
[[package]]
name = "markupsafe"
version = "2.1.4"
description = "Safely add untrusted strings to HTML/XML markup."
optional = false
python-versions = ">=3.7"
files = [
{file = "MarkupSafe-2.1.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:de8153a7aae3835484ac168a9a9bdaa0c5eee4e0bc595503c95d53b942879c84"},
{file = "MarkupSafe-2.1.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e888ff76ceb39601c59e219f281466c6d7e66bd375b4ec1ce83bcdc68306796b"},
{file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0b838c37ba596fcbfca71651a104a611543077156cb0a26fe0c475e1f152ee8"},
{file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac1ebf6983148b45b5fa48593950f90ed6d1d26300604f321c74a9ca1609f8e"},
{file = "MarkupSafe-2.1.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fbad3d346df8f9d72622ac71b69565e621ada2ce6572f37c2eae8dacd60385d"},
{file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5291d98cd3ad9a562883468c690a2a238c4a6388ab3bd155b0c75dd55ece858"},
{file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a7cc49ef48a3c7a0005a949f3c04f8baa5409d3f663a1b36f0eba9bfe2a0396e"},
{file = "MarkupSafe-2.1.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b83041cda633871572f0d3c41dddd5582ad7d22f65a72eacd8d3d6d00291df26"},
{file = "MarkupSafe-2.1.4-cp310-cp310-win32.whl", hash = "sha256:0c26f67b3fe27302d3a412b85ef696792c4a2386293c53ba683a89562f9399b0"},
{file = "MarkupSafe-2.1.4-cp310-cp310-win_amd64.whl", hash = "sha256:a76055d5cb1c23485d7ddae533229039b850db711c554a12ea64a0fd8a0129e2"},
{file = "MarkupSafe-2.1.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9e9e3c4020aa2dc62d5dd6743a69e399ce3de58320522948af6140ac959ab863"},
{file = "MarkupSafe-2.1.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0042d6a9880b38e1dd9ff83146cc3c9c18a059b9360ceae207805567aacccc69"},
{file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d03fea4c4e9fd0ad75dc2e7e2b6757b80c152c032ea1d1de487461d8140efc"},
{file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ab3a886a237f6e9c9f4f7d272067e712cdb4efa774bef494dccad08f39d8ae6"},
{file = "MarkupSafe-2.1.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abf5ebbec056817057bfafc0445916bb688a255a5146f900445d081db08cbabb"},
{file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e1a0d1924a5013d4f294087e00024ad25668234569289650929ab871231668e7"},
{file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e7902211afd0af05fbadcc9a312e4cf10f27b779cf1323e78d52377ae4b72bea"},
{file = "MarkupSafe-2.1.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c669391319973e49a7c6230c218a1e3044710bc1ce4c8e6eb71f7e6d43a2c131"},
{file = "MarkupSafe-2.1.4-cp311-cp311-win32.whl", hash = "sha256:31f57d64c336b8ccb1966d156932f3daa4fee74176b0fdc48ef580be774aae74"},
{file = "MarkupSafe-2.1.4-cp311-cp311-win_amd64.whl", hash = "sha256:54a7e1380dfece8847c71bf7e33da5d084e9b889c75eca19100ef98027bd9f56"},
{file = "MarkupSafe-2.1.4-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:a76cd37d229fc385738bd1ce4cba2a121cf26b53864c1772694ad0ad348e509e"},
{file = "MarkupSafe-2.1.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:987d13fe1d23e12a66ca2073b8d2e2a75cec2ecb8eab43ff5624ba0ad42764bc"},
{file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5244324676254697fe5c181fc762284e2c5fceeb1c4e3e7f6aca2b6f107e60dc"},
{file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78bc995e004681246e85e28e068111a4c3f35f34e6c62da1471e844ee1446250"},
{file = "MarkupSafe-2.1.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4d176cfdfde84f732c4a53109b293d05883e952bbba68b857ae446fa3119b4f"},
{file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f9917691f410a2e0897d1ef99619fd3f7dd503647c8ff2475bf90c3cf222ad74"},
{file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:f06e5a9e99b7df44640767842f414ed5d7bedaaa78cd817ce04bbd6fd86e2dd6"},
{file = "MarkupSafe-2.1.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:396549cea79e8ca4ba65525470d534e8a41070e6b3500ce2414921099cb73e8d"},
{file = "MarkupSafe-2.1.4-cp312-cp312-win32.whl", hash = "sha256:f6be2d708a9d0e9b0054856f07ac7070fbe1754be40ca8525d5adccdbda8f475"},
{file = "MarkupSafe-2.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:5045e892cfdaecc5b4c01822f353cf2c8feb88a6ec1c0adef2a2e705eef0f656"},
{file = "MarkupSafe-2.1.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:7a07f40ef8f0fbc5ef1000d0c78771f4d5ca03b4953fc162749772916b298fc4"},
{file = "MarkupSafe-2.1.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d18b66fe626ac412d96c2ab536306c736c66cf2a31c243a45025156cc190dc8a"},
{file = "MarkupSafe-2.1.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:698e84142f3f884114ea8cf83e7a67ca8f4ace8454e78fe960646c6c91c63bfa"},
{file = "MarkupSafe-2.1.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49a3b78a5af63ec10d8604180380c13dcd870aba7928c1fe04e881d5c792dc4e"},
{file = "MarkupSafe-2.1.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:15866d7f2dc60cfdde12ebb4e75e41be862348b4728300c36cdf405e258415ec"},
{file = "MarkupSafe-2.1.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:6aa5e2e7fc9bc042ae82d8b79d795b9a62bd8f15ba1e7594e3db243f158b5565"},
{file = "MarkupSafe-2.1.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:54635102ba3cf5da26eb6f96c4b8c53af8a9c0d97b64bdcb592596a6255d8518"},
{file = "MarkupSafe-2.1.4-cp37-cp37m-win32.whl", hash = "sha256:3583a3a3ab7958e354dc1d25be74aee6228938312ee875a22330c4dc2e41beb0"},
{file = "MarkupSafe-2.1.4-cp37-cp37m-win_amd64.whl", hash = "sha256:d6e427c7378c7f1b2bef6a344c925b8b63623d3321c09a237b7cc0e77dd98ceb"},
{file = "MarkupSafe-2.1.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:bf1196dcc239e608605b716e7b166eb5faf4bc192f8a44b81e85251e62584bd2"},
{file = "MarkupSafe-2.1.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4df98d4a9cd6a88d6a585852f56f2155c9cdb6aec78361a19f938810aa020954"},
{file = "MarkupSafe-2.1.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b835aba863195269ea358cecc21b400276747cc977492319fd7682b8cd2c253d"},
{file = "MarkupSafe-2.1.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23984d1bdae01bee794267424af55eef4dfc038dc5d1272860669b2aa025c9e3"},
{file = "MarkupSafe-2.1.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c98c33ffe20e9a489145d97070a435ea0679fddaabcafe19982fe9c971987d5"},
{file = "MarkupSafe-2.1.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9896fca4a8eb246defc8b2a7ac77ef7553b638e04fbf170bff78a40fa8a91474"},
{file = "MarkupSafe-2.1.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:b0fe73bac2fed83839dbdbe6da84ae2a31c11cfc1c777a40dbd8ac8a6ed1560f"},
{file = "MarkupSafe-2.1.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:c7556bafeaa0a50e2fe7dc86e0382dea349ebcad8f010d5a7dc6ba568eaaa789"},
{file = "MarkupSafe-2.1.4-cp38-cp38-win32.whl", hash = "sha256:fc1a75aa8f11b87910ffd98de62b29d6520b6d6e8a3de69a70ca34dea85d2a8a"},
{file = "MarkupSafe-2.1.4-cp38-cp38-win_amd64.whl", hash = "sha256:3a66c36a3864df95e4f62f9167c734b3b1192cb0851b43d7cc08040c074c6279"},
{file = "MarkupSafe-2.1.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:765f036a3d00395a326df2835d8f86b637dbaf9832f90f5d196c3b8a7a5080cb"},
{file = "MarkupSafe-2.1.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:21e7af8091007bf4bebf4521184f4880a6acab8df0df52ef9e513d8e5db23411"},
{file = "MarkupSafe-2.1.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5c31fe855c77cad679b302aabc42d724ed87c043b1432d457f4976add1c2c3e"},
{file = "MarkupSafe-2.1.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7653fa39578957bc42e5ebc15cf4361d9e0ee4b702d7d5ec96cdac860953c5b4"},
{file = "MarkupSafe-2.1.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:47bb5f0142b8b64ed1399b6b60f700a580335c8e1c57f2f15587bd072012decc"},
{file = "MarkupSafe-2.1.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:fe8512ed897d5daf089e5bd010c3dc03bb1bdae00b35588c49b98268d4a01e00"},
{file = "MarkupSafe-2.1.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:36d7626a8cca4d34216875aee5a1d3d654bb3dac201c1c003d182283e3205949"},
{file = "MarkupSafe-2.1.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b6f14a9cd50c3cb100eb94b3273131c80d102e19bb20253ac7bd7336118a673a"},
{file = "MarkupSafe-2.1.4-cp39-cp39-win32.whl", hash = "sha256:c8f253a84dbd2c63c19590fa86a032ef3d8cc18923b8049d91bcdeeb2581fbf6"},
{file = "MarkupSafe-2.1.4-cp39-cp39-win_amd64.whl", hash = "sha256:8b570a1537367b52396e53325769608f2a687ec9a4363647af1cded8928af959"},
{file = "MarkupSafe-2.1.4.tar.gz", hash = "sha256:3aae9af4cac263007fd6309c64c6ab4506dd2b79382d9d19a1994f9240b8db4f"},
]
[[package]]
name = "packaging"
version = "23.2"
@ -578,6 +808,42 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
[package.extras]
testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-asyncio"
version = "0.23.3"
description = "Pytest support for asyncio"
optional = false
python-versions = ">=3.8"
files = [
{file = "pytest-asyncio-0.23.3.tar.gz", hash = "sha256:af313ce900a62fbe2b1aed18e37ad757f1ef9940c6b6a88e2954de38d6b1fb9f"},
{file = "pytest_asyncio-0.23.3-py3-none-any.whl", hash = "sha256:37a9d912e8338ee7b4a3e917381d1c95bfc8682048cb0fbc35baba316ec1faba"},
]
[package.dependencies]
pytest = ">=7.0.0"
[package.extras]
docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"]
testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"]
[[package]]
name = "pytest-cov"
version = "4.1.0"
description = "Pytest plugin for measuring coverage."
optional = false
python-versions = ">=3.7"
files = [
{file = "pytest-cov-4.1.0.tar.gz", hash = "sha256:3904b13dfbfec47f003b8e77fd5b589cd11904a21ddf1ab38a64f204d6a10ef6"},
{file = "pytest_cov-4.1.0-py3-none-any.whl", hash = "sha256:6ba70b9e97e69fcc3fb45bfeab2d0a138fb65c4d0d6a41ef33983ad114be8c3a"},
]
[package.dependencies]
coverage = {version = ">=5.2.1", extras = ["toml"]}
pytest = ">=4.6"
[package.extras]
testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"]
[[package]]
name = "python-dotenv"
version = "1.0.0"
@ -751,4 +1017,4 @@ standard = ["colorama (>=0.4)", "httptools (>=0.5.0)", "python-dotenv (>=0.13)",
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
content-hash = "84fe024aa665ddad3077ca7e73054d1a5cb019a9a3e78af917922433ff4b3d8c"
content-hash = "21d68c7a50ac5fb0af89a33a484094ea6a69f1e9a03d0f8cb5c6dc35400c760a"

View File

@ -14,6 +14,10 @@ asyncpg = "^0.29.0"
pydantic-settings = "^2.1.0"
psycopg2-binary = "^2.9.9"
email-validator = "^2.1.0.post1"
pytest-asyncio = "^0.23.3"
httpx = "^0.26.0"
pytest-cov = "^4.1.0"
alembic = "^1.13.1"
[tool.poetry.group.dev.dependencies]
@ -25,3 +29,7 @@ build-backend = "poetry.core.masonry.api"
[tool.pytest.ini_options]
pythonpath = ". fastfood"
filterwarnings = [
"ignore::UserWarning",
"ignore::DeprecationWarning"
]

1
scripts/db_prepare.sql Normal file
View File

@ -0,0 +1 @@
CREATE DATABASE fastfood_db_test WITH OWNER postgres;

View File

@ -0,0 +1,5 @@
#!/bin/bash
#
# Тут можно выполнить миграции или дополнительные перед запуском приложения
#
poetry run python manage.py --run-test-server

2
scripts/testing.sh Normal file
View File

@ -0,0 +1,2 @@
#!/bin/bash
poetry run pytest -vv

69
tests/conftest.py Normal file
View File

@ -0,0 +1,69 @@
import asyncio
from typing import AsyncGenerator
from httpx import AsyncClient
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from fastfood.app import create_app
from fastfood.config import settings
from fastfood.dbase import get_async_session
from fastfood.models import Base
async_engine = create_async_engine(settings.TESTDATABASE_URL_asyncpg)
async_session_maker = async_sessionmaker(
async_engine,
class_=AsyncSession,
expire_on_commit=False,
)
@pytest.fixture(scope="session")
def event_loop():
try:
loop = asyncio.get_event_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope="function", autouse=True)
async def db_init():
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
yield
async with async_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
async def get_test_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
yield session
@pytest.fixture(scope="session")
def app():
app = create_app()
app.dependency_overrides[get_async_session] = get_test_session
yield app
@pytest_asyncio.fixture(scope="function")
async def client(app):
async with AsyncClient(
app=app, base_url="http://localhost:8000/api/v1/menus",
) as async_client:
yield async_client
@pytest_asyncio.fixture(scope="function")
async def asession() -> AsyncGenerator[AsyncSession, None]:
async with async_session_maker() as session:
yield session

383
tests/test_api.py Normal file
View File

@ -0,0 +1,383 @@
import pytest
class TestBaseCrud:
class Menu:
@staticmethod
async def read_all(ac):
"""чтение всех меню"""
response = await ac.get("/")
return response.status_code, response.json()
@staticmethod
async def get(ac, data):
"""Получение меню по id"""
response = await ac.get(f"/{data.get('id')}")
return response.status_code, response.json()
@staticmethod
async def write(ac, data):
"""создания меню"""
response = await ac.post("/", json=data)
return response.status_code, response.json()
@staticmethod
async def update(ac, data):
"""Обновление меню по id"""
response = await ac.patch(f"/{data.get('id')}", json=data)
return response.status_code, response.json()
@staticmethod
async def delete(ac, data):
"""Удаление меню по id"""
response = await ac.delete(f"/{data.get('id')}")
return response.status_code
class Submenu:
@staticmethod
async def read_all(ac, menu):
"""чтение всех меню"""
response = await ac.get(f"/{menu.get('id')}/submenus/")
return response.status_code, response.json()
@staticmethod
async def get(ac, menu, submenu):
"""Получение меню по id"""
response = await ac.get(
f"/{menu.get('id')}/submenus/{submenu.get('id')}",
)
return response.status_code, response.json()
@staticmethod
async def write(ac, menu, submenu):
"""создания меню"""
response = await ac.post(
f"/{menu.get('id')}/submenus/",
json=submenu,
)
return response.status_code, response.json()
@staticmethod
async def update(ac, menu, submenu):
"""Обновление меню по id"""
response = await ac.patch(
f"/{menu.get('id')}/submenus/{submenu.get('id')}",
json=submenu,
)
return response.status_code, response.json()
@staticmethod
async def delete(ac, menu, submenu):
"""Удаление меню по id"""
response = await ac.delete(
f"/{menu.get('id')}/submenus/{submenu.get('id')}"
)
return response.status_code
class Dish:
@staticmethod
async def read_all(ac, menu, submenu):
"""чтение всех блюд"""
response = await ac.get(
f"/{menu.get('id')}/submenus/{submenu.get('id')}/dishes/",
)
return response.status_code, response.json()
@staticmethod
async def get(ac, menu, submenu, dish):
"""Получение блюда по id"""
response = await ac.get(
f"/{menu.get('id')}/submenus/{submenu.get('id')}"
f"/dishes/{dish.get('id')}",
)
return response.status_code, response.json()
@staticmethod
async def write(ac, menu, submenu, dish):
"""создания блюда"""
response = await ac.post(
f"/{menu.get('id')}/submenus/{submenu.get('id')}/dishes/",
json=dish,
)
return response.status_code, response.json()
@staticmethod
async def update(ac, menu, submenu, dish):
"""Обновление блюда по id"""
response = await ac.patch(
f"/{menu.get('id')}/submenus/{submenu.get('id')}"
f"/dishes/{dish.get('id')}",
json=dish,
)
return response.status_code, response.json()
@staticmethod
async def delete(ac, menu, submenu, dish):
"""Удаление блюда по id"""
response = await ac.delete(
f"/{menu.get('id')}/submenus/{submenu.get('id')}"
f"/dishes/{dish.get('id')}"
)
return response.status_code
@pytest.mark.asyncio
async def test_menu_crud(self, client):
"""Тестирование функций меню"""
code, rspn = await self.Menu.read_all(client)
assert code == 200
data = {"title": "Menu", "description": None}
code, rspn = await self.Menu.write(client, data)
assert code == 201
assert rspn["title"] == "Menu"
assert rspn["description"] is None
code, menu = await self.Menu.get(client, {"id": rspn.get("id")})
assert code == 200
assert menu["title"] == rspn["title"]
upd_data = {
"id": rspn.get("id"),
"title": "upd Menu",
"description": "",
}
code, upd_rspn = await self.Menu.update(client, upd_data)
assert code == 200
assert upd_rspn["title"] == "upd Menu"
code = await self.Menu.delete(client, rspn)
assert code == 200
code, menu = await self.Menu.get(client, {"id": rspn.get("id")})
assert code == 404
@pytest.mark.asyncio
async def test_submenus(self, client):
# Создаем меню и проверяем ответ
menu = {"title": "Menu", "description": "main menu"}
code, rspn = await self.Menu.write(client, menu)
assert code == 201
menu.update(rspn)
# Проверяем наличие подменю
code, rspn = await self.Submenu.read_all(client, menu)
assert code == 200
assert rspn == []
# Создаем и проверяем подменю
submenu = {
"title": "Submenu",
"description": "submenu",
"parent_menu": menu["id"],
}
code, rspn = await self.Submenu.write(client, menu, submenu)
assert code == 201
submenu.update(rspn)
# Проверяем меню на наличие подменю
code, rspn = await self.Menu.get(client, menu)
assert code == 200
assert rspn["submenus_count"] == 1
# Обновляем подменю и проверяем
submenu["title"] = "updated_submenu"
code, rspn = await self.Submenu.update(client, menu, submenu)
assert code == 200
assert submenu["title"] == rspn["title"]
submenu.update(rspn)
# Удаляем подменю
code = await self.Submenu.delete(client, menu, submenu)
assert code == 200
# Проверяем меню
code, rspn = await self.Menu.get(client, menu)
assert code == 200
assert rspn["submenus_count"] == 0
# Проверяем удаленное подменю
code, rspn = await self.Submenu.get(client, menu, submenu)
assert code == 404
# удаляем сопутствующее
await self.Menu.delete(client, menu)
@pytest.mark.asyncio
async def test_dishes(self, client):
# Создаем меню и проверяем ответ
menu = {
"title": "Menu",
"description": "main menu",
}
code, rspn = await self.Menu.write(client, menu)
assert code == 201
menu.update(rspn)
# Создаем и проверяем подменю
submenu = {
"title": "Submenu",
"description": "submenu",
"parent_menu": menu["id"],
}
code, rspn = await self.Submenu.write(client, menu, submenu)
assert code == 201
submenu.update(rspn)
# Проверяем все блюда в подменю
code, rspn = await self.Dish.read_all(client, menu, submenu)
assert code == 200
assert rspn == []
# Добавляем блюдо
dish = {
"title": "dish",
"description": "some dish",
"price": "12.5",
"parent_submenu": submenu["id"],
}
code, rspn = await self.Dish.write(client, menu, submenu, dish)
assert code == 201
dish.update(rspn)
# Получаем блюдо
code, rspn = await self.Dish.get(client, menu, submenu, dish)
assert code == 200
assert rspn["title"] == dish["title"]
# Проверяем меню на количество блюд
code, rspn = await self.Menu.get(client, menu)
assert code == 200
assert rspn["dishes_count"] == 1
# Проверяем подменю на наличие блюд
code, rspn = await self.Submenu.get(client, menu, submenu)
assert code == 200
assert rspn["dishes_count"] == 1
# Обновляем блюдо и проверяем
dish["title"] = "updated_dish"
code, rspn = await self.Dish.update(client, menu, submenu, dish)
assert code == 200
assert dish["title"] == rspn["title"]
dish.update(rspn)
# Удаляем подменю
code = await self.Dish.delete(client, menu, submenu, dish)
assert code == 200
# Проверяем меню
code, rspn = await self.Menu.get(client, menu)
assert code == 200
assert rspn["dishes_count"] == 0
# Проверяем подменю на наличие блюд
code, rspn = await self.Submenu.get(client, menu, submenu)
assert code == 200
assert rspn["dishes_count"] == 0
# Проверяем удаленное блюдо
code, rspn = await self.Dish.get(client, menu, submenu, dish)
assert code == 404
# удаляем сопутствующее
await self.Submenu.delete(client, menu, submenu)
await self.Menu.delete(client, menu)
class TestСontinuity:
@pytest.mark.asyncio
async def test_postman_continuity(self, client):
# Создаем меню
menu = {
"title": "Menu",
"description": "main menu",
}
code, rspn = await TestBaseCrud.Menu.write(client, menu)
assert code == 201
assert "id" in rspn.keys()
menu.update(rspn)
# Создаем подменю
submenu = {
"title": "Submenu",
"description": "submenu",
"parent_menu": menu["id"],
}
code, rspn = await TestBaseCrud.Submenu.write(client, menu, submenu)
assert code == 201
assert "id" in rspn.keys()
submenu.update(rspn)
# Добавляем блюдо1
dish = {
"title": "dish1",
"description": "some dish1",
"price": "13.50",
"parent_submenu": submenu["id"],
}
code, rspn = await TestBaseCrud.Dish.write(client, menu, submenu, dish)
assert code == 201
assert "id" in rspn.keys()
dish.update(rspn)
# Добавляем блюдо2
dish = {
"title": "dish2",
"description": "some dish2",
"price": "12.50",
"parent_submenu": submenu["id"],
}
code, rspn = await TestBaseCrud.Dish.write(client, menu, submenu, dish)
assert code == 201
assert "id" in rspn.keys()
dish.update(rspn)
# Просматриваем конкретное меню
code, rspn = await TestBaseCrud.Menu.get(client, menu)
assert code == 200
assert "id" in rspn.keys()
assert menu["id"] == rspn["id"]
assert "submenus_count" in rspn.keys()
assert rspn["submenus_count"] == 1
assert "dishes_count" in rspn.keys()
assert rspn["dishes_count"] == 2
# Просматриваем конкретное подменю
code, rspn = await TestBaseCrud.Submenu.get(client, menu, submenu)
assert code == 200
assert "id" in rspn.keys()
assert "dishes_count" in rspn.keys()
assert rspn["dishes_count"] == 2
# Удаляем подменю
code = await TestBaseCrud.Submenu.delete(client, menu, submenu)
assert code == 200
# Просматриваем список подменю
code, rspn = await TestBaseCrud.Submenu.read_all(client, menu)
assert code == 200
assert rspn == []
# Просматриваем список блюд
code, rspn = await TestBaseCrud.Dish.read_all(client, menu, submenu)
assert code == 200
assert rspn == []
# Просматриваем конкретное меню
code, rspn = await TestBaseCrud.Menu.get(client, menu)
assert code == 200
assert "id" in rspn.keys()
assert menu["id"] == rspn["id"]
assert "submenus_count" in rspn.keys()
assert rspn["submenus_count"] == 0
assert "dishes_count" in rspn.keys()
assert rspn["dishes_count"] == 0
# Удаляем меню
code = await TestBaseCrud.Menu.delete(client, menu)
assert code == 200
# Просматриваем все меню
code, rspn = await TestBaseCrud.Menu.read_all(client)
assert code == 200
assert rspn == []

100
tests/test_crud.py Normal file
View File

@ -0,0 +1,100 @@
from uuid import UUID
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from fastfood.cruds.menu import MenuCrud
from fastfood.cruds.submenu import SubMenuCrud
from fastfood.models import Menu, SubMenu
from fastfood.schemas import Menu as menuschema
from fastfood.schemas import MenuBase as menubaseschema
@pytest.mark.asyncio
async def test_menu(asession: AsyncSession) -> None:
async with asession:
# Создаем меню
menu: Menu = Menu(title="SomeMenu", description="SomeDescription")
menu: Menu = await MenuCrud.create_menu_item(
menubaseschema.model_validate(menu),
asession,
)
menu_id: UUID = menu.id
# Получаем его же
req_menu: Menu | None = await MenuCrud.get_menu_item(menu_id, asession)
assert menu == req_menu
# Получаем все меню и проверяем
req_menus = await MenuCrud.get_menus(asession)
assert menu == req_menus.scalars().all()[0]
# Обновляем
menu.title = "updatedMenu"
await MenuCrud.update_menu_item(
menu.id, menuschema.model_validate(menu), asession
)
# И сверяем
req_menu = await MenuCrud.get_menu_item(menu_id, asession)
assert menu == req_menu
# Удаляем и проверяем
await MenuCrud.delete_menu_item(menu_id, asession)
req_menus = await MenuCrud.get_menus(asession)
assert req_menus.all() == []
@pytest.mark.asyncio
async def test_submenu(asession: AsyncSession) -> None:
async with asession:
# Создаем меню напрямую
menu: Menu = Menu(title="SomeMenu", description="SomeDescription")
asession.add(menu)
await asession.commit()
await asession.refresh(menu)
menu_id: UUID = menu.id
# Создаем подменю
submenu: SubMenu = SubMenu(
title="submenu",
description="",
parent_menu=menu_id,
)
submenu = await SubMenuCrud.create_submenu_item(
menu_id,
menubaseschema.model_validate(submenu),
asession,
)
submenu_id = submenu.id
# Проверяем подменю
req_submenu = await SubMenuCrud.get_submenu_item(
menu_id, submenu.id, asession,
)
assert submenu == req_submenu
# Обновляем меню
submenu.title = "UpdatedSubmenu"
req_submenu = await SubMenuCrud.update_submenu_item(
submenu_id, menubaseschema.model_validate(submenu), asession,
)
assert submenu == req_submenu.scalar_one_or_none()
menu = await MenuCrud.get_menu_item(menu_id, asession)
assert 1 == menu.submenus_count
# Удаляем полменю
await SubMenuCrud.delete_submenu_item(submenu_id, asession)
menu = await MenuCrud.get_menu_item(menu_id, asession)
assert 0 == menu.submenus_count
await MenuCrud.delete_menu_item(menu_id, asession)
@pytest.mark.skip
@pytest.mark.asyncio
async def test_dish(asession: AsyncSession):
"""Not Implemented yet"""
async with asession:
pass