From 13435d59ffabc240aa8f097aa3fe4e34b2672184 Mon Sep 17 00:00:00 2001 From: matyushkovvv Date: Sun, 21 Sep 2025 22:49:07 +0300 Subject: [PATCH 1/5] Implement ASGI application with fibonacci, factorial, and mean endpoints --- hw1/app.py | 127 +++++++++++++++++++++++++++++++++++++++++++++++- hw1/services.py | 25 ++++++++++ 2 files changed, 150 insertions(+), 2 deletions(-) create mode 100644 hw1/services.py diff --git a/hw1/app.py b/hw1/app.py index 6107b870..507a087c 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,7 @@ from typing import Any, Awaitable, Callable +from services import factorial, fibonacci, mean +import json +import re async def application( @@ -12,8 +15,128 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if scope['type'] != 'http': + return + + method = scope['method'] + path = scope['path'] + + if method != 'GET': + await send_response(send, 404, {'error': 'Not found'}) + return + + if path == '/mean': + await handle_mean(scope, receive, send) + elif path.startswith('/fibonacci/'): + await handle_fibonacci(path, send) + elif path.startswith('/factorial'): + await handle_factorial(scope, send) + else: + await send_response(send, 404, {'error': 'Not found'}) + + +async def handle_fibonacci(path: str, send: Callable): + try: + n_str = path.split('/fibonacci/')[-1] + n = int(n_str) + + if n < 0: + await send_response(send, 400, {'error': 'Parameter "n" must be non-negative'}) + return + + result = fibonacci(n) + await send_response(send, 200, {'result': result}) + except (ValueError, IndexError): + # Теперь будет возвращать 422 вместо 404 + await send_response(send, 422, {'error': 'Invalid parameter format'}) + except Exception as e: + await send_response(send, 500, {'error': f'Internal server error: {str(e)}'}) + + +async def handle_mean(scope: dict[str, Any], receive: Callable, send: Callable): + + body = await get_request_body(receive) + + try: + if not body: + await send_response(send, 422, {'error': 'No data provided'}) + return + + data = json.loads(body) + if not isinstance(data, list): + await send_response(send, 400, {'error': 'Expected list of numbers'}) + return + + if len(data) == 0: + await send_response(send, 400, {'error': 'Empty list'}) + return + + numbers = [float(num) for num in data] + result = mean(numbers) + + await send_response(send, 200, {'result': result}) + except (ValueError, TypeError): + await send_response(send, 422, {'error': 'Invalid numbers format'}) + except Exception as e: + await send_response(send, 500, {'error': f'Internal server error: {str(e)}'}) + + +async def handle_factorial(scope: dict[str, Any], send: Callable): + query_string = scope.get('query_string', b'').decode() + params = parse_query_params(query_string) + + if 'n' not in params: + await send_response(send, 422, {'error': 'Missing "n" parameter'}) + return + + try: + n = int(params['n']) + if n < 0: + await send_response(send, 400, {'error': 'Parameter "n" must be non-negative'}) + return + + result = factorial(n) + await send_response(send, 200, {'result': result}) + except ValueError: + await send_response(send, 422, {'error': 'Parameter "n" must be an integer'}) + except Exception as e: + await send_response(send, 500, {'error': f'Internal server error: {str(e)}'}) + + +async def get_request_body(receive: Callable) -> str: + body = b'' + more_body = True + + while more_body: + message = await receive() + body += message.get('body', b'') + more_body = message.get('more_body', False) + + return body.decode('utf-8') + + +def parse_query_params(query_string: str) -> dict: + params = {} + if query_string: + for param in query_string.split('&'): + if '=' in param: + key, value = param.split('=', 1) + params[key] = value + return params + + +async def send_response(send: Callable, status: int, data: dict): + await send({ + 'type': 'http.response.start', + 'status': status, + 'headers': [[b'content-type', b'application/json']], + }) + await send({ + 'type': 'http.response.body', + 'body': json.dumps(data).encode('utf-8'), + }) + if __name__ == "__main__": import uvicorn - uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) + uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) \ No newline at end of file diff --git a/hw1/services.py b/hw1/services.py new file mode 100644 index 00000000..739dcd62 --- /dev/null +++ b/hw1/services.py @@ -0,0 +1,25 @@ +from typing import List + +def factorial(n: int) -> int: + if n < 0: + return 0 + + if n == 0: + return 1 + + return n * factorial(n - 1) + + +def fibonacci(n: int) -> int: + if n <= 0: + return 0 + + elif n == 1: + return 1 + + else: + return fibonacci(n - 1) + fibonacci(n - 2) + + +def mean(numbers: List[any]) -> float: + return sum(numbers) / len(numbers) From 9d3b7ab17fb68fd782f62e9d95d02185cc1600d8 Mon Sep 17 00:00:00 2001 From: matyushkovvv Date: Sat, 1 Nov 2025 20:16:54 +0300 Subject: [PATCH 2/5] Implement ASGI application with fibonacci, factorial, and mean endpoints --- hw1/app.py | 113 +++++++++++++++++++++++++++++++++++------------- hw1/services.py | 25 ----------- 2 files changed, 84 insertions(+), 54 deletions(-) delete mode 100644 hw1/services.py diff --git a/hw1/app.py b/hw1/app.py index 507a087c..65738ef8 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,7 +1,36 @@ from typing import Any, Awaitable, Callable -from services import factorial, fibonacci, mean import json -import re + + +def fibonacci(n: int) -> int: + """Вычисляет n-е число Фибоначчи""" + if n == 0: + return 0 + if n == 1: + return 1 + + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + return b + + +def factorial(n: int) -> int: + """Вычисляет факториал n""" + if n == 0: + return 1 + + result = 1 + for i in range(1, n + 1): + result *= i + return result + + +def mean(numbers: list[float]) -> float: + """Вычисляет среднее арифметическое""" + if not numbers: + return 0.0 + return sum(numbers) / len(numbers) async def application( @@ -15,9 +44,19 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ + if scope['type'] == 'lifespan': + while True: + message = await receive() + if message['type'] == 'lifespan.startup': + await send({'type': 'lifespan.startup.complete'}) + elif message['type'] == 'lifespan.shutdown': + await send({'type': 'lifespan.shutdown.complete'}) + break + return + if scope['type'] != 'http': return - + method = scope['method'] path = scope['path'] @@ -29,81 +68,95 @@ async def application( await handle_mean(scope, receive, send) elif path.startswith('/fibonacci/'): await handle_fibonacci(path, send) - elif path.startswith('/factorial'): + elif path == '/factorial': await handle_factorial(scope, send) else: await send_response(send, 404, {'error': 'Not found'}) async def handle_fibonacci(path: str, send: Callable): + """Обработчик для /fibonacci/{n}""" try: - n_str = path.split('/fibonacci/')[-1] + # Извлекаем n из пути + n_str = path.split('/fibonacci/')[-1].split('/')[0] n = int(n_str) - + if n < 0: - await send_response(send, 400, {'error': 'Parameter "n" must be non-negative'}) + await send_response(send, 400, {'error': 'n must be non-negative'}) return - + result = fibonacci(n) await send_response(send, 200, {'result': result}) + except (ValueError, IndexError): - # Теперь будет возвращать 422 вместо 404 await send_response(send, 422, {'error': 'Invalid parameter format'}) - except Exception as e: - await send_response(send, 500, {'error': f'Internal server error: {str(e)}'}) + except Exception: + await send_response(send, 500, {'error': 'Internal server error'}) -async def handle_mean(scope: dict[str, Any], receive: Callable, send: Callable): - +async def handle_mean( + scope: dict[str, Any], receive: Callable, send: Callable +): + """Обработчик для /mean с JSON в теле запроса""" + # Получаем тело запроса body = await get_request_body(receive) + + if not body: + await send_response(send, 422, {'error': 'No data provided'}) + return try: - if not body: - await send_response(send, 422, {'error': 'No data provided'}) - return - + # Парсим JSON из тела запроса data = json.loads(body) + if not isinstance(data, list): - await send_response(send, 400, {'error': 'Expected list of numbers'}) + await send_response( + send, 422, {'error': 'Expected list of numbers'} + ) return if len(data) == 0: await send_response(send, 400, {'error': 'Empty list'}) return - + + # Конвертируем все числа в float numbers = [float(num) for num in data] result = mean(numbers) - + await send_response(send, 200, {'result': result}) + except (ValueError, TypeError): await send_response(send, 422, {'error': 'Invalid numbers format'}) - except Exception as e: - await send_response(send, 500, {'error': f'Internal server error: {str(e)}'}) + except Exception: + await send_response(send, 500, {'error': 'Internal server error'}) async def handle_factorial(scope: dict[str, Any], send: Callable): + """Обработчик для /factorial?n=5""" query_string = scope.get('query_string', b'').decode() params = parse_query_params(query_string) - + if 'n' not in params: - await send_response(send, 422, {'error': 'Missing "n" parameter'}) + await send_response(send, 422, {'error': 'Missing n parameter'}) return try: n = int(params['n']) if n < 0: - await send_response(send, 400, {'error': 'Parameter "n" must be non-negative'}) + await send_response(send, 400, {'error': 'n must be non-negative'}) return - + result = factorial(n) await send_response(send, 200, {'result': result}) + except ValueError: - await send_response(send, 422, {'error': 'Parameter "n" must be an integer'}) - except Exception as e: - await send_response(send, 500, {'error': f'Internal server error: {str(e)}'}) + await send_response(send, 422, {'error': 'n must be an integer'}) + except Exception: + await send_response(send, 500, {'error': 'Internal server error'}) async def get_request_body(receive: Callable) -> str: + """Получает тело запроса""" body = b'' more_body = True @@ -116,6 +169,7 @@ async def get_request_body(receive: Callable) -> str: def parse_query_params(query_string: str) -> dict: + """Парсит query string в словарь параметров""" params = {} if query_string: for param in query_string.split('&'): @@ -126,6 +180,7 @@ def parse_query_params(query_string: str) -> dict: async def send_response(send: Callable, status: int, data: dict): + """Утилита для отправки JSON ответа""" await send({ 'type': 'http.response.start', 'status': status, diff --git a/hw1/services.py b/hw1/services.py deleted file mode 100644 index 739dcd62..00000000 --- a/hw1/services.py +++ /dev/null @@ -1,25 +0,0 @@ -from typing import List - -def factorial(n: int) -> int: - if n < 0: - return 0 - - if n == 0: - return 1 - - return n * factorial(n - 1) - - -def fibonacci(n: int) -> int: - if n <= 0: - return 0 - - elif n == 1: - return 1 - - else: - return fibonacci(n - 1) + fibonacci(n - 2) - - -def mean(numbers: List[any]) -> float: - return sum(numbers) / len(numbers) From ee92cca752776a2a8ec0b8562aaec2867d53de4d Mon Sep 17 00:00:00 2001 From: matyushkovvv Date: Sat, 1 Nov 2025 20:30:43 +0300 Subject: [PATCH 3/5] complete hw2 --- hw2/hw/shop_api/main.py | 166 +++++++++++++++++++++++++++++++++- hw2/hw/shop_api/models.py | 21 +++++ hw2/hw/shop_api/repository.py | 82 +++++++++++++++++ 3 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 hw2/hw/shop_api/models.py create mode 100644 hw2/hw/shop_api/repository.py diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..2f46b248 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,167 @@ -from fastapi import FastAPI +from typing import List, Optional + +from fastapi import FastAPI, HTTPException, status +from fastapi.encoders import jsonable_encoder +from fastapi.responses import Response, JSONResponse +from pydantic import NonNegativeInt, PositiveInt, NonNegativeFloat, BaseModel + +from shop_api.models import CartItem, Cart, Item +from shop_api.repository import CartNotFoundException, CartsRepository, ItemNotFoundException, ItemsRepository app = FastAPI(title="Shop API") + + +@app.post("/cart") +async def create_cart(): + new_cart: Cart = CartsRepository.create_cart() + return JSONResponse( + content={"id": new_cart.id}, + headers={"location": f"/cart/{new_cart.id}"}, + status_code=status.HTTP_201_CREATED, + ) + + +@app.get("/cart/{id}") +async def get_cart(id: NonNegativeInt): + try: + cart = CartsRepository.get_cart(id) + except CartNotFoundException: + raise HTTPException(status_code=404, detail="Cart not found") + return cart + + +@app.get("/cart") +async def get_carts(offset: NonNegativeInt = 0, + limit: PositiveInt = 10, + min_price: Optional[NonNegativeFloat] = None, + max_price: Optional[NonNegativeFloat] = None, + min_quantity: Optional[NonNegativeInt] = None, + max_quantity: Optional[NonNegativeInt] = None): + carts: List[Cart] = CartsRepository.get_carts(offset, limit) + result = [] + for cart in carts: + if min_price is not None and cart.price < min_price: + continue + if max_price is not None and cart.price > max_price: + continue + + cart_quantity = sum([item.quantity for item in cart.items]) + if min_quantity is not None and cart_quantity < min_quantity: + continue + if max_quantity is not None and cart_quantity > max_quantity: + continue + + result.append(cart) + + return result + + +@app.post("/cart/{cart_id}/add/{item_id}") +async def add_item_to_cart(cart_id: NonNegativeInt, item_id: NonNegativeInt): + try: + cart: Cart = CartsRepository.get_cart(cart_id) + except CartNotFoundException: + raise HTTPException(status_code=404, detail="Cart not found") + + try: + item: Item = ItemsRepository.get_item(item_id) + except ItemNotFoundException: + raise HTTPException(status_code=404, detail="Item not found") + + for cart_item in cart.items: + if cart_item.id == item.id: + cart_item.quantity += 1 + break + else: + cart.items.append(CartItem(id=item.id, name=item.name, quantity=1, + available=not item.deleted)) + cart.price += item.price + + try: + CartsRepository.update_cart(cart) + except CartNotFoundException: + raise HTTPException(status_code=500, detail="Internal server error") + + +class CreateItemRequestBody(BaseModel): + name: str + price: float + + +@app.post("/item", status_code=201) +async def create_item(body: CreateItemRequestBody): + new_item: Item = ItemsRepository.create_item(name=body.name, price=body.price) + return JSONResponse( + content=jsonable_encoder(new_item), + headers={"location": f"/item/{new_item.id}"}, + status_code=status.HTTP_201_CREATED, + ) + + +@app.get("/item/{id}") +async def get_item(id: NonNegativeInt): + try: + item: Item = ItemsRepository.get_item(id) + except ItemNotFoundException: + raise HTTPException(status_code=404, detail="Item not found") + return item + + +@app.get("/item") +async def get_items(offset: NonNegativeInt = 0, + limit: PositiveInt = 10, + min_price: Optional[NonNegativeFloat] = None, + max_price: Optional[NonNegativeFloat] = None, + show_deleted: bool = False): + items: List[Item] = ItemsRepository.get_items(offset, limit) + result = [] + for item in items: + if min_price is not None and item.price < min_price: + continue + if max_price is not None and item.price > max_price: + continue + if not show_deleted and item.deleted: + continue + + result.append(item) + + return result + + +class ReplaceItemRequestBody(BaseModel): + name: str + price: float + + +@app.put("/item/{id}") +async def replace_item(id: NonNegativeInt, body: ReplaceItemRequestBody): + try: + item: Item = ItemsRepository.replace_item(item_id=id, name=body.name, price=body.price) + except ItemNotFoundException: + raise HTTPException(status_code=404, detail="Item not found") + return item + + +class UpdateItemRequestBody(BaseModel): + model_config = {"extra": "forbid"} + + name: Optional[str] = None + price: Optional[float] = None + + +@app.patch("/item/{id}") +async def update_item(id: NonNegativeInt, body: UpdateItemRequestBody): + try: + updated_item: Optional[Item] = ItemsRepository.update_item( + item_id=id, name=body.name, price=body.price) + except ItemNotFoundException: + raise HTTPException(status_code=404, detail="Item not found") + + if not updated_item: + return Response(status_code=status.HTTP_304_NOT_MODIFIED) + return updated_item + + +@app.delete("/item/{id}") +async def delete_item(id: NonNegativeInt): + ItemsRepository.delete_item(item_id=id) diff --git a/hw2/hw/shop_api/models.py b/hw2/hw/shop_api/models.py new file mode 100644 index 00000000..2185fe31 --- /dev/null +++ b/hw2/hw/shop_api/models.py @@ -0,0 +1,21 @@ +from typing import List + +from pydantic import BaseModel + + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + +class Cart(BaseModel): + id: int + items: List[CartItem] + price: float + +class Item(BaseModel): + id: int + name: str + price: float + deleted: bool diff --git a/hw2/hw/shop_api/repository.py b/hw2/hw/shop_api/repository.py new file mode 100644 index 00000000..eb8622f5 --- /dev/null +++ b/hw2/hw/shop_api/repository.py @@ -0,0 +1,82 @@ +from typing import List, Optional + +from shop_api.models import CartItem, Cart, Item + + +carts: List[Cart] = [] + +class CartNotFoundException(Exception): + pass + +class CartsRepository: + def create_cart() -> Cart: + cart_id = len(carts) + cart = Cart(id=cart_id, items=[], price=0.0) + carts.append(cart) + return cart.model_copy(deep=True) + + def get_cart(cart_id: int) -> Cart: + if cart_id >= len(carts): + raise CartNotFoundException() + return carts[cart_id].model_copy(deep=True) + + def get_carts(offset: int, limit: int) -> List[Cart]: + return [cart.model_copy(deep=True) for cart in carts[offset:offset+limit]] + + def update_cart(new_cart: Cart): + if new_cart.id > len(carts): + raise CartNotFoundException() + carts[new_cart.id] = new_cart + + +items: List[Item] = [] + +class ItemNotFoundException(Exception): + pass + +class ItemsRepository: + def create_item(name: str, price: float) -> Item: + item_id = len(items) + item = Item(id=item_id, name=name, price=price, deleted=False) + items.append(item) + return item.model_copy(deep=True) + + def _get_item(item_id: int) -> Item: + if item_id >= len(items): + raise ItemNotFoundException() + return items[item_id] + + def get_item(item_id: int) -> Item: + item: Item = ItemsRepository._get_item(item_id) + if item.deleted: + raise ItemNotFoundException() + + return item.model_copy(deep=True) + + def get_items(offset: int, limit: int) -> List[Item]: + return [item.model_copy(deep=True) for item in items[offset:offset+limit]] + + def replace_item(item_id: int, name: str, price: float) -> Item: + if item_id >= len(items): + raise ItemNotFoundException() + + item = Item(id=item_id, name=name, price=price, deleted=False) + items[item_id] = item + return item.model_copy(deep=True) + + def update_item(item_id: int, name: Optional[str], price: Optional[float]) -> Optional[Item]: + item: Item = ItemsRepository._get_item(item_id) + + if item.deleted: + return None + + if name is not None: + item.name = name + if price is not None: + item.price = price + + return item.model_copy(deep=True) + + def delete_item(item_id: int): + item: Item = ItemsRepository._get_item(item_id) + item.deleted = True From 8b1b77eebf81db1e303b3e68ccfe5ea713aa20e4 Mon Sep 17 00:00:00 2001 From: matyushkovvv Date: Sat, 1 Nov 2025 20:40:06 +0300 Subject: [PATCH 4/5] update hw 3 --- hw2/hw/Dockerfile.txt | 23 +++++++++++++++ hw2/hw/docker-compose.yml | 36 +++++++++++++++++++++++ hw2/hw/requirements.txt | 2 ++ hw2/hw/settings/prometheus/prometheus.yml | 10 +++++++ hw2/hw/shop_api/main.py | 7 +++++ 5 files changed, 78 insertions(+) create mode 100644 hw2/hw/Dockerfile.txt create mode 100644 hw2/hw/docker-compose.yml create mode 100644 hw2/hw/settings/prometheus/prometheus.yml diff --git a/hw2/hw/Dockerfile.txt b/hw2/hw/Dockerfile.txt new file mode 100644 index 00000000..27b0b636 --- /dev/null +++ b/hw2/hw/Dockerfile.txt @@ -0,0 +1,23 @@ +FROM python:3.12 AS base + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR $APP_ROOT/src +COPY . ./ + +ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ + PATH=$APP_ROOT/src/.venv/bin:$PATH + +RUN pip install -r requirements.txt + +FROM base as local + +CMD ["uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..1564ac24 --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,36 @@ +version: "3" + +services: + + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + volumes: + - grafana-storage:/var/lib/grafana + + prometheus: + image: prom/prometheus + volumes: + - ./settings/prometheus/:/etc/prometheus/ + command: + - "--config.file=/etc/prometheus/prometheus.yml" + - "--storage.tsdb.path=/prometheus" + - "--web.console.libraries=/usr/share/prometheus/console_libraries" + - "--web.console.templates=/usr/share/prometheus/consoles" + ports: + - 9090:9090 + restart: always + +volumes: + grafana-storage: {} diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..545007ac 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,6 +1,8 @@ # Основные зависимости для ASGI приложения fastapi>=0.117.1 uvicorn>=0.24.0 +prometheus-client +prometheus-fastapi-instrumentator # Зависимости для тестирования pytest>=7.4.0 diff --git a/hw2/hw/settings/prometheus/prometheus.yml b/hw2/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..7fa1951b --- /dev/null +++ b/hw2/hw/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-api-local + metrics_path: /metrics + static_configs: + - targets: + - local:8080 diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index 2f46b248..5095db76 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -3,12 +3,15 @@ from fastapi import FastAPI, HTTPException, status from fastapi.encoders import jsonable_encoder from fastapi.responses import Response, JSONResponse +from prometheus_client import Counter +from prometheus_fastapi_instrumentator import Instrumentator from pydantic import NonNegativeInt, PositiveInt, NonNegativeFloat, BaseModel from shop_api.models import CartItem, Cart, Item from shop_api.repository import CartNotFoundException, CartsRepository, ItemNotFoundException, ItemsRepository app = FastAPI(title="Shop API") +instrumentator = Instrumentator().instrument(app).expose(app) @app.post("/cart") @@ -21,8 +24,12 @@ async def create_cart(): ) +test_get_cart_counter = Counter('get_cart_counter', 'Test custum counter') + + @app.get("/cart/{id}") async def get_cart(id: NonNegativeInt): + test_get_cart_counter.inc() try: cart = CartsRepository.get_cart(id) except CartNotFoundException: From 70e2e33862867ab09f436ba67d37ec5bb158025f Mon Sep 17 00:00:00 2001 From: matyushkovvv Date: Sat, 1 Nov 2025 20:56:32 +0300 Subject: [PATCH 5/5] completed hw4 --- hw4/.gitignore | 219 ++++++++++++++ hw4/app/alembic.ini | 147 ++++++++++ hw4/app/api/__init__.py | 8 + hw4/app/api/v1/__init__.py | 12 + hw4/app/api/v1/cart.py | 58 ++++ hw4/app/api/v1/item.py | 71 +++++ hw4/app/config.py | 46 +++ hw4/app/core/__init__.py | 28 ++ hw4/app/core/mongo.py | 28 ++ hw4/app/crud/__init__.py | 139 +++++++++ hw4/app/crud/cart..py | 139 +++++++++ hw4/app/crud/item.py | 157 ++++++++++ hw4/app/main.py | 38 +++ hw4/app/schemas/__init__.py | 14 + hw4/app/schemas/cart.py | 14 + hw4/app/schemas/item.py | 28 ++ hw4/app/utils/mongo.py | 28 ++ hw4/docker-compose.yml | 16 + hw4/env.template | 6 + hw4/poetry.lock | 569 ++++++++++++++++++++++++++++++++++++ hw4/project.toml | 21 ++ 21 files changed, 1786 insertions(+) create mode 100644 hw4/.gitignore create mode 100644 hw4/app/alembic.ini create mode 100644 hw4/app/api/__init__.py create mode 100644 hw4/app/api/v1/__init__.py create mode 100644 hw4/app/api/v1/cart.py create mode 100644 hw4/app/api/v1/item.py create mode 100644 hw4/app/config.py create mode 100644 hw4/app/core/__init__.py create mode 100644 hw4/app/core/mongo.py create mode 100644 hw4/app/crud/__init__.py create mode 100644 hw4/app/crud/cart..py create mode 100644 hw4/app/crud/item.py create mode 100644 hw4/app/main.py create mode 100644 hw4/app/schemas/__init__.py create mode 100644 hw4/app/schemas/cart.py create mode 100644 hw4/app/schemas/item.py create mode 100644 hw4/app/utils/mongo.py create mode 100644 hw4/docker-compose.yml create mode 100644 hw4/env.template create mode 100644 hw4/poetry.lock create mode 100644 hw4/project.toml diff --git a/hw4/.gitignore b/hw4/.gitignore new file mode 100644 index 00000000..75accd55 --- /dev/null +++ b/hw4/.gitignore @@ -0,0 +1,219 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +# Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +# poetry.lock +# poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +# pdm.lock +# pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +# pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# Redis +*.rdb +*.aof +*.pid + +# RabbitMQ +mnesia/ +rabbitmq/ +rabbitmq-data/ + +# ActiveMQ +activemq-data/ + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +.vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml + +# Custom +.drafts/ \ No newline at end of file diff --git a/hw4/app/alembic.ini b/hw4/app/alembic.ini new file mode 100644 index 00000000..a5f3267a --- /dev/null +++ b/hw4/app/alembic.ini @@ -0,0 +1,147 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts. +# this is typically a path given in POSIX (e.g. forward slashes) +# format, relative to the token %(here)s which refers to the location of this +# ini file +script_location = %(here)s/alembic + +# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s +# Uncomment the line below if you want the files to be prepended with date and time +# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file +# for all available tokens +file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. for multiple paths, the path separator +# is defined by "path_separator" below. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the tzdata library which can be installed by adding +# `alembic[tz]` to the pip requirements. +# string value is passed to ZoneInfo() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to /versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "path_separator" +# below. +# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions + +# path_separator; This indicates what character is used to split lists of file +# paths, including version_locations and prepend_sys_path within configparser +# files such as alembic.ini. +# The default rendered in new alembic.ini files is "os", which uses os.pathsep +# to provide os-dependent path splitting. +# +# Note that in order to support legacy alembic.ini files, this default does NOT +# take place if path_separator is not present in alembic.ini. If this +# option is omitted entirely, fallback logic is as follows: +# +# 1. Parsing of the version_locations option falls back to using the legacy +# "version_path_separator" key, which if absent then falls back to the legacy +# behavior of splitting on spaces and/or commas. +# 2. Parsing of the prepend_sys_path option falls back to the legacy +# behavior of splitting on spaces, commas, or colons. +# +# Valid values for path_separator are: +# +# path_separator = : +# path_separator = ; +# path_separator = space +# path_separator = newline +# +# Use os.pathsep. Default configuration used for new projects. +path_separator = os + + +# set to 'true' to search source files recursively +# in each "version_locations" directory +# new in Alembic version 1.10 +# recursive_version_locations = false + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +# database URL. This is consumed by the user-maintained env.py script only. +# other means of configuring database URLs may be customized within the env.py +# file. +sqlalchemy.url = driver://user:pass@localhost/dbname + + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +hooks = black +black.type = console_scripts +black.entrypoint = black +black.options = -l 120 REVISION_SCRIPT_FILENAME + +# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module +# hooks = ruff +# ruff.type = module +# ruff.module = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Alternatively, use the exec runner to execute a binary found on your PATH +# hooks = ruff +# ruff.type = exec +# ruff.executable = ruff +# ruff.options = check --fix REVISION_SCRIPT_FILENAME + +# Logging configuration. This is also consumed by the user-maintained +# env.py script only. +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S \ No newline at end of file diff --git a/hw4/app/api/__init__.py b/hw4/app/api/__init__.py new file mode 100644 index 00000000..090f1d90 --- /dev/null +++ b/hw4/app/api/__init__.py @@ -0,0 +1,8 @@ +from fastapi import APIRouter + +from app.api.v1 import router as router_v1 +from app.config import get_settings + +router = APIRouter(prefix=get_settings().prefix.v1) + +router.include_router(router_v1) \ No newline at end of file diff --git a/hw4/app/api/v1/__init__.py b/hw4/app/api/v1/__init__.py new file mode 100644 index 00000000..4021164f --- /dev/null +++ b/hw4/app/api/v1/__init__.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +from app.config import get_settings + +from .cart import router as router_cart +from .item import router as router_item + +router = APIRouter(prefix=get_settings().prefix.v1, tags=["v1"]) + +router.include_router(router_item) + +router.include_router(router_cart) \ No newline at end of file diff --git a/hw4/app/api/v1/cart.py b/hw4/app/api/v1/cart.py new file mode 100644 index 00000000..aa891d17 --- /dev/null +++ b/hw4/app/api/v1/cart.py @@ -0,0 +1,58 @@ +from fastapi import APIRouter, Depends, status +from fastapi.responses import JSONResponse + +import app.crud.cart as crud_cart +from app.core.mongo import get_mongo + +# Роутер корзин +router = APIRouter(tags=["cart"]) + + +@router.post("/cart") +async def create_cart(mongo=Depends(get_mongo)): + cart = await crud_cart.create_cart(mongo) + return JSONResponse( + content=cart.model_dump(), + status_code=status.HTTP_201_CREATED, + headers={"location": f"/cart/{cart.id}"}, + ) + + +@router.get("/cart/{cart_id}") +async def get_cart_by_id(cart_id: int, mongo=Depends(get_mongo)): + cart = await crud_cart.get_cart_by_id(mongo, cart_id) + if cart is None: + return JSONResponse(content={}, status_code=status.HTTP_404_NOT_FOUND) + else: + return JSONResponse(content=cart.model_dump(), status_code=status.HTTP_200_OK) + + +@router.get("/cart") +async def get_carts( + offset: int = 0, + limit: int = 10, + min_price: float = None, + max_price: float = None, + min_quantity: int = None, + max_quantity: int = None, + mongo=Depends(get_mongo), +): + carts, code = await crud_cart.get_carts(mongo, offset, limit, min_price, max_price, min_quantity, max_quantity) + if code == 422: + return JSONResponse(content=[], status_code=status.HTTP_422_UNPROCESSABLE_CONTENT) + return JSONResponse(content=[cart.model_dump() for cart in carts], status_code=status.HTTP_200_OK) + + +@router.post("/cart/{cart_id}/add/{item_id}") +async def add_item_to_cart(cart_id: int, item_id: int, mongo=Depends(get_mongo)): + cart, code = await crud_cart.add_item_to_cart(mongo, cart_id, item_id) + if code == 404: + return JSONResponse(content={}, status_code=status.HTTP_404_NOT_FOUND) + return JSONResponse( + content={ + "id": cart.id, + "price": cart.price, + "items": [item.model_dump() for item in cart.items], + }, + status_code=status.HTTP_200_OK, + ) \ No newline at end of file diff --git a/hw4/app/api/v1/item.py b/hw4/app/api/v1/item.py new file mode 100644 index 00000000..fa0249b1 --- /dev/null +++ b/hw4/app/api/v1/item.py @@ -0,0 +1,71 @@ +from fastapi import APIRouter, Depends, status +from fastapi.responses import JSONResponse + +import app.crud.item as crud_item +from app.core.mongo import get_mongo +from app.schemas.item import CreateItem, PatchItem, UpdateItem + +# Роутер товаров +router = APIRouter(tags=["item"]) + + +@router.post("/item") +async def create_item(item: CreateItem, mongo=Depends(get_mongo)): + item = await crud_item.create_item(mongo, item) + return JSONResponse( + content=item.model_dump(), + status_code=status.HTTP_201_CREATED, + headers={"location": f"/item/{item.id}"}, + ) + + +@router.get("/item/{item_id}") +async def get_item_by_id(item_id: int, mongo=Depends(get_mongo)): + item = await crud_item.get_item_by_id(mongo, item_id) + if item is None: + return JSONResponse(content={}, status_code=status.HTTP_404_NOT_FOUND) + else: + return JSONResponse(content=item.model_dump(), status_code=status.HTTP_200_OK) + + +@router.put("/item/{item_id}") +async def update_item(item_id: int, item: UpdateItem, mongo=Depends(get_mongo)): + item = await crud_item.update_item(mongo, item_id, item) + if item is None: + return JSONResponse(content={}, status_code=status.HTTP_404_NOT_FOUND) + else: + return JSONResponse(content=item.model_dump(), status_code=status.HTTP_200_OK) + + +@router.patch("/item/{item_id}") +async def patch_item(item_id: int, item: PatchItem, mongo=Depends(get_mongo)): + item, code = await crud_item.patch_item(mongo, item_id, item) + if code == 404: + return JSONResponse(content={}, status_code=status.HTTP_404_NOT_FOUND) + if code == 304: + return JSONResponse(content={}, status_code=status.HTTP_304_NOT_MODIFIED) + return JSONResponse(content=item.model_dump(), status_code=status.HTTP_200_OK) + + +@router.delete("/item/{item_id}") +async def delete_item(item_id: int, mongo=Depends(get_mongo)): + item = await crud_item.delete_item(mongo, item_id) + if item is None: + return JSONResponse(content={}, status_code=status.HTTP_404_NOT_FOUND) + else: + return JSONResponse(content=item.model_dump(), status_code=status.HTTP_200_OK) + + +@router.get("/item") +async def get_items( + offset: int = 0, + limit: int = 10, + min_price: float = None, + max_price: float = None, + show_deleted: bool = False, + mongo=Depends(get_mongo), +): + items, code = await crud_item.get_items(mongo, offset, limit, min_price, max_price, show_deleted) + if code == 422: + return JSONResponse(content=[], status_code=status.HTTP_422_UNPROCESSABLE_CONTENT) + return JSONResponse(content=[item.model_dump() for item in items], status_code=status.HTTP_200_OK) \ No newline at end of file diff --git a/hw4/app/config.py b/hw4/app/config.py new file mode 100644 index 00000000..0ed1db33 --- /dev/null +++ b/hw4/app/config.py @@ -0,0 +1,46 @@ +from functools import lru_cache +from pathlib import Path + +from pydantic import BaseModel +from pydantic_settings import BaseSettings, SettingsConfigDict +from tomlkit import item + +BASE_DIR = Path(__file__).parent + + +class MongoConfig(BaseModel): + url: str + host: str + port: int + username: str + password: str + + db_name: str = "mongo_db" + pool_size: int = 100 + + +class PrefixConfig(BaseModel): + v1: str = "" + api: str = "" + + +class AppConfig(BaseModel): + host: str = "0.0.0.0" + port: int = 8000 + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=(".env.template", ".env"), + case_sensitive=False, + env_nested_delimiter="__", + ) + + app: AppConfig = AppConfig() + prefix: PrefixConfig = PrefixConfig() + mongo: MongoConfig + + +@lru_cache() +def get_settings() -> Settings: + return Settings() \ No newline at end of file diff --git a/hw4/app/core/__init__.py b/hw4/app/core/__init__.py new file mode 100644 index 00000000..39507a2e --- /dev/null +++ b/hw4/app/core/__init__.py @@ -0,0 +1,28 @@ +from motor.motor_asyncio import AsyncIOMotorClient + +from app.config import get_settings + + +class MongoDB: + def __init__(self, client: AsyncIOMotorClient, db_name: str): + """ + Create a MongoDB client. + :param client: The AsyncIOMotorClient to use. + :param db_name: The name of the database to use. + """ + self.client = client + self.mongo = client[db_name] + + +# Create a singleton MongoDB client +client = AsyncIOMotorClient(get_settings().mongo.url, maxPoolSize=get_settings().mongo.pool_size) +mongo_db = MongoDB(client, get_settings().mongo.db_name) + + +async def get_mongo() -> MongoDB: + """ + Dependency for FastAPI to get a MongoDB client. + This function will be used as a dependency in FastAPI endpoints to get a MongoDB client. + It returns the singleton MongoDB client created with the specified pool size. + """ + return mongo_db \ No newline at end of file diff --git a/hw4/app/core/mongo.py b/hw4/app/core/mongo.py new file mode 100644 index 00000000..39507a2e --- /dev/null +++ b/hw4/app/core/mongo.py @@ -0,0 +1,28 @@ +from motor.motor_asyncio import AsyncIOMotorClient + +from app.config import get_settings + + +class MongoDB: + def __init__(self, client: AsyncIOMotorClient, db_name: str): + """ + Create a MongoDB client. + :param client: The AsyncIOMotorClient to use. + :param db_name: The name of the database to use. + """ + self.client = client + self.mongo = client[db_name] + + +# Create a singleton MongoDB client +client = AsyncIOMotorClient(get_settings().mongo.url, maxPoolSize=get_settings().mongo.pool_size) +mongo_db = MongoDB(client, get_settings().mongo.db_name) + + +async def get_mongo() -> MongoDB: + """ + Dependency for FastAPI to get a MongoDB client. + This function will be used as a dependency in FastAPI endpoints to get a MongoDB client. + It returns the singleton MongoDB client created with the specified pool size. + """ + return mongo_db \ No newline at end of file diff --git a/hw4/app/crud/__init__.py b/hw4/app/crud/__init__.py new file mode 100644 index 00000000..fd2d06a1 --- /dev/null +++ b/hw4/app/crud/__init__.py @@ -0,0 +1,139 @@ +from pymongo import DESCENDING, ReturnDocument + +from app.schemas.cart import Cart, ItemCart +from app.schemas.item import CreateItem, Item, PatchItem, UpdateItem + + +async def create_cart(mongo) -> Cart: + """ + Создаёт новую корзину с уникальным id и пустым списком items. + """ + collection = mongo.mongo["carts"] + + # Генерация id на основе последнего документа + last_cart = await collection.find_one(sort=[("id", DESCENDING)]) + new_id = last_cart["id"] + 1 if last_cart and "id" in last_cart else 1 + + # Создаём Pydantic объект + new_cart = Cart(id=new_id, items=[], price=0.0) + + # Вставляем в Mongo + await collection.insert_one(new_cart.model_dump()) + + return new_cart + + +async def get_cart_by_id(mongo, cart_id: int) -> Cart | None: + """ + Получение корзины по id. + Возвращает Pydantic-модель Cart или None, если корзина не найдена. + """ + collection = mongo.mongo["carts"] + + doc = await collection.find_one({"id": cart_id}) + if not doc: + return None + + return Cart(**doc) + + +async def get_carts( + mongo, + offset: int = 0, + limit: int = 10, + min_price: float | None = None, + max_price: float | None = None, + min_quantity: int | None = None, + max_quantity: int | None = None, +) -> tuple[list[Cart] | None, int]: + """ + Получение списка корзин с фильтрацией и пагинацией. + Возвращает (list[Cart], status_code) + """ + # Валидация входных параметров + if ( + offset < 0 + or limit <= 0 + or (min_price is not None and min_price < 0) + or (max_price is not None and max_price < 0) + or (min_quantity is not None and min_quantity < 0) + or (max_quantity is not None and max_quantity < 0) + ): + return None, 422 + + collection = mongo.mongo["carts"] + + # Формируем фильтр для MongoDB + filter_query = {} + + if min_price is not None or max_price is not None: + filter_query["price"] = {} + if min_price is not None: + filter_query["price"]["$gte"] = min_price + if max_price is not None: + filter_query["price"]["$lte"] = max_price + + if min_quantity is not None or max_quantity is not None: + filter_query["items"] = {} + if min_quantity is not None: + filter_query["items"]["$size"] = {"$gte": min_quantity} # $size не поддерживает диапазоны + # MongoDB не поддерживает напрямую max для $size, фильтруем после + # поэтому будем фильтровать вручную ниже + + # Получаем документы с сортировкой по id и пагинацией + cursor = collection.find(filter_query).sort("id", 1).skip(offset).limit(limit) + carts = [Cart(**doc) async for doc in cursor] + + # Дополнительная фильтрация по max_quantity, так как $size не поддерживает диапазон + if max_quantity is not None: + carts = [cart for cart in carts if len(cart.items) <= max_quantity] + + return carts, 200 + + +async def add_item_to_cart(mongo, cart_id: int, item_id: int) -> tuple[Cart | None, int]: + """ + Добавляет товар в корзину. Если товар уже есть — увеличивает quantity. + Возвращает (Cart, status_code) или (None, 404), если корзина/товар не найдены. + """ + from app.crud.item import get_item_by_id + + carts_collection = mongo.mongo["carts"] + + # Получаем корзину + cart_doc = await carts_collection.find_one({"id": cart_id}) + if not cart_doc: + return None, 404 + + # Получаем товар + item_to_add = await get_item_by_id(mongo, item_id) + if not item_to_add: + return None, 404 + + # Флаг, был ли товар добавлен + is_item_added = False + cart_items = cart_doc.get("items", []) + for idx, cart_item in enumerate(cart_items): + if cart_item["id"] == item_id: + cart_items[idx]["quantity"] += 1 + cart_doc["price"] += item_to_add.price + is_item_added = True + break + + # Если товара нет в корзине, добавляем новый + if not is_item_added: + new_cart_item = ItemCart( + id=item_to_add.id, name=item_to_add.name, quantity=1, available=not item_to_add.deleted + ) + cart_items.append(new_cart_item.model_dump()) + cart_doc["price"] += item_to_add.price + + # Обновляем корзину в MongoDB + cart_doc["items"] = cart_items + updated_cart_doc = await carts_collection.find_one_and_update( + {"id": cart_id}, + {"$set": {"items": cart_items, "price": cart_doc["price"]}}, + return_document=ReturnDocument.AFTER, + ) + + return Cart(**updated_cart_doc), 200 \ No newline at end of file diff --git a/hw4/app/crud/cart..py b/hw4/app/crud/cart..py new file mode 100644 index 00000000..fd2d06a1 --- /dev/null +++ b/hw4/app/crud/cart..py @@ -0,0 +1,139 @@ +from pymongo import DESCENDING, ReturnDocument + +from app.schemas.cart import Cart, ItemCart +from app.schemas.item import CreateItem, Item, PatchItem, UpdateItem + + +async def create_cart(mongo) -> Cart: + """ + Создаёт новую корзину с уникальным id и пустым списком items. + """ + collection = mongo.mongo["carts"] + + # Генерация id на основе последнего документа + last_cart = await collection.find_one(sort=[("id", DESCENDING)]) + new_id = last_cart["id"] + 1 if last_cart and "id" in last_cart else 1 + + # Создаём Pydantic объект + new_cart = Cart(id=new_id, items=[], price=0.0) + + # Вставляем в Mongo + await collection.insert_one(new_cart.model_dump()) + + return new_cart + + +async def get_cart_by_id(mongo, cart_id: int) -> Cart | None: + """ + Получение корзины по id. + Возвращает Pydantic-модель Cart или None, если корзина не найдена. + """ + collection = mongo.mongo["carts"] + + doc = await collection.find_one({"id": cart_id}) + if not doc: + return None + + return Cart(**doc) + + +async def get_carts( + mongo, + offset: int = 0, + limit: int = 10, + min_price: float | None = None, + max_price: float | None = None, + min_quantity: int | None = None, + max_quantity: int | None = None, +) -> tuple[list[Cart] | None, int]: + """ + Получение списка корзин с фильтрацией и пагинацией. + Возвращает (list[Cart], status_code) + """ + # Валидация входных параметров + if ( + offset < 0 + or limit <= 0 + or (min_price is not None and min_price < 0) + or (max_price is not None and max_price < 0) + or (min_quantity is not None and min_quantity < 0) + or (max_quantity is not None and max_quantity < 0) + ): + return None, 422 + + collection = mongo.mongo["carts"] + + # Формируем фильтр для MongoDB + filter_query = {} + + if min_price is not None or max_price is not None: + filter_query["price"] = {} + if min_price is not None: + filter_query["price"]["$gte"] = min_price + if max_price is not None: + filter_query["price"]["$lte"] = max_price + + if min_quantity is not None or max_quantity is not None: + filter_query["items"] = {} + if min_quantity is not None: + filter_query["items"]["$size"] = {"$gte": min_quantity} # $size не поддерживает диапазоны + # MongoDB не поддерживает напрямую max для $size, фильтруем после + # поэтому будем фильтровать вручную ниже + + # Получаем документы с сортировкой по id и пагинацией + cursor = collection.find(filter_query).sort("id", 1).skip(offset).limit(limit) + carts = [Cart(**doc) async for doc in cursor] + + # Дополнительная фильтрация по max_quantity, так как $size не поддерживает диапазон + if max_quantity is not None: + carts = [cart for cart in carts if len(cart.items) <= max_quantity] + + return carts, 200 + + +async def add_item_to_cart(mongo, cart_id: int, item_id: int) -> tuple[Cart | None, int]: + """ + Добавляет товар в корзину. Если товар уже есть — увеличивает quantity. + Возвращает (Cart, status_code) или (None, 404), если корзина/товар не найдены. + """ + from app.crud.item import get_item_by_id + + carts_collection = mongo.mongo["carts"] + + # Получаем корзину + cart_doc = await carts_collection.find_one({"id": cart_id}) + if not cart_doc: + return None, 404 + + # Получаем товар + item_to_add = await get_item_by_id(mongo, item_id) + if not item_to_add: + return None, 404 + + # Флаг, был ли товар добавлен + is_item_added = False + cart_items = cart_doc.get("items", []) + for idx, cart_item in enumerate(cart_items): + if cart_item["id"] == item_id: + cart_items[idx]["quantity"] += 1 + cart_doc["price"] += item_to_add.price + is_item_added = True + break + + # Если товара нет в корзине, добавляем новый + if not is_item_added: + new_cart_item = ItemCart( + id=item_to_add.id, name=item_to_add.name, quantity=1, available=not item_to_add.deleted + ) + cart_items.append(new_cart_item.model_dump()) + cart_doc["price"] += item_to_add.price + + # Обновляем корзину в MongoDB + cart_doc["items"] = cart_items + updated_cart_doc = await carts_collection.find_one_and_update( + {"id": cart_id}, + {"$set": {"items": cart_items, "price": cart_doc["price"]}}, + return_document=ReturnDocument.AFTER, + ) + + return Cart(**updated_cart_doc), 200 \ No newline at end of file diff --git a/hw4/app/crud/item.py b/hw4/app/crud/item.py new file mode 100644 index 00000000..60a184d7 --- /dev/null +++ b/hw4/app/crud/item.py @@ -0,0 +1,157 @@ +from pymongo import DESCENDING, ReturnDocument + +from app.schemas.item import CreateItem, Item, PatchItem, UpdateItem + + +async def _get_next_id(mongo) -> int: + """Берёт последний id из коллекции и возвращает следующий.""" + collection = mongo.mongo["items"] + last_doc = await collection.find_one(sort=[("id", DESCENDING)]) + if last_doc and "id" in last_doc: + return int(last_doc["id"]) + 1 + return 1 + + +async def create_item(mongo, item: CreateItem) -> Item: + """Создаёт новый элемент и возвращает Item (Pydantic-модель).""" + collection = mongo.mongo["items"] + new_id = await _get_next_id(mongo) + new_item = Item(id=new_id, name=item.name, price=item.price) + await collection.insert_one(new_item.model_dump()) + return new_item + + +async def get_item_by_id(mongo, item_id: int) -> Item | None: + """ + Возвращает элемент по id в виде Pydantic-схемы Item. + Игнорирует элементы с deleted=True. + """ + collection = mongo.mongo["items"] + doc = await collection.find_one({"id": item_id, "$or": [{"deleted": {"$exists": False}}, {"deleted": False}]}) + + if not doc: + return None + + # Преобразуем документ Mongo в Pydantic модель + return Item(**doc) + + +async def update_item(mongo, item_id: int, item: UpdateItem) -> Item | None: + """ + Обновляет существующий элемент по id. + Возвращает обновлённую Pydantic-модель Item или None, если элемента нет. + """ + collection = mongo.mongo["items"] + + # Подготовка полей для обновления + update_data = { + "name": item.name, + "price": item.price, + "deleted": item.deleted if item.deleted is not None else False, + } + + # Находим и обновляем документ атомарно + updated_doc = await collection.find_one_and_update( + {"id": item_id}, {"$set": update_data}, return_document=ReturnDocument.AFTER + ) + + if not updated_doc: + return None + + return Item(**updated_doc) + + +async def patch_item(mongo, item_id: int, item: PatchItem) -> tuple[Item | None, int]: + """ + Патч элемента по id. + Возвращает (Item, status_code): + - 200: элемент обновлён + - 304: изменений нет + - 404: элемент не найден + """ + collection = mongo.mongo["items"] + + # Получаем текущий документ + current_doc = await collection.find_one({"id": item_id}) + if not current_doc or current_doc.get("deleted", False): + return None, 404 + + # Проверяем, есть ли реальные изменения + if (item.name is None or item.name == current_doc["name"]) and ( + item.price is None or item.price == current_doc["price"] + ): + return Item(**current_doc), 304 + + # Подготавливаем поля для обновления + update_data = {} + if item.name is not None: + update_data["name"] = item.name + if item.price is not None: + update_data["price"] = item.price + + # Обновляем документ + updated_doc = await collection.find_one_and_update( + {"id": item_id}, {"$set": update_data}, return_document=ReturnDocument.AFTER + ) + + return Item(**updated_doc), 200 + + +async def delete_item(mongo, item_id: int) -> Item | None: + """ + Soft delete элемента по id. + Возвращает Pydantic-модель Item или None, если элемент не найден. + """ + collection = mongo.mongo["items"] + + # Находим и обновляем документ, устанавливая deleted=True + deleted_doc = await collection.find_one_and_update( + {"id": item_id}, {"$set": {"deleted": True}}, return_document=ReturnDocument.AFTER + ) + + if not deleted_doc: + return None + + return Item(**deleted_doc) + + +async def get_items( + mongo, + offset: int = 0, + limit: int = 10, + min_price: float | None = None, + max_price: float | None = None, + show_deleted: bool = False, +) -> tuple[list[Item] | None, int]: + """ + Получение списка элементов с фильтрацией, пагинацией и опцией show_deleted. + Возвращает (list[Item], status_code) + """ + # Валидация входных параметров + if offset is not None and offset < 0: + return None, 422 + if limit is not None and limit <= 0: + return None, 422 + if min_price is not None and min_price < 0: + return None, 422 + if max_price is not None and max_price < 0: + return None, 422 + + collection = mongo.mongo["items"] + + # Формируем фильтр для Mongo + filter_query = {} + if not show_deleted: + filter_query["$or"] = [{"deleted": {"$exists": False}}, {"deleted": False}] + if min_price is not None: + filter_query["price"] = filter_query.get("price", {}) + filter_query["price"]["$gte"] = min_price + if max_price is not None: + filter_query["price"] = filter_query.get("price", {}) + filter_query["price"]["$lte"] = max_price + + # Получаем документы с сортировкой по id и пагинацией + cursor = collection.find(filter_query).sort("id", 1).skip(offset).limit(limit) + items = [Item(**doc) async for doc in cursor] + + return items, 200 \ No newline at end of file diff --git a/hw4/app/main.py b/hw4/app/main.py new file mode 100644 index 00000000..97765019 --- /dev/null +++ b/hw4/app/main.py @@ -0,0 +1,38 @@ +from contextlib import asynccontextmanager + +import uvicorn +from fastapi import FastAPI +from loguru import logger + +from app.api import router as router_api +from app.config import get_settings +from app.core.mongo import client as mongo_client +from app.core.mongo import get_mongo +from app.utils.mongo import init_mongo_collections + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # --- Launch MongoDB --- + mongo = await get_mongo() + mongodb = mongo.mongo + await init_mongo_collections() + logger.info("MongoDB client ready") + # --- Yield to FastAPI --- + try: + logger.success("Application is running") + yield + finally: + # --- Close MongoDB --- + mongo_client.close() + logger.info("MongoDB client closed") + logger.success("Application is stopped") + + +app = FastAPI(lifespan=lifespan) + +app.include_router(router_api) + + +if __name__ == "__main__": + uvicorn.run(app, host=get_settings().app.host, port=get_settings().app.port) \ No newline at end of file diff --git a/hw4/app/schemas/__init__.py b/hw4/app/schemas/__init__.py new file mode 100644 index 00000000..3269f6c8 --- /dev/null +++ b/hw4/app/schemas/__init__.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + + +class ItemCart(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class Cart(BaseModel): + id: int + items: list[ItemCart] = [] + price: float = 0.0 \ No newline at end of file diff --git a/hw4/app/schemas/cart.py b/hw4/app/schemas/cart.py new file mode 100644 index 00000000..3269f6c8 --- /dev/null +++ b/hw4/app/schemas/cart.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + + +class ItemCart(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class Cart(BaseModel): + id: int + items: list[ItemCart] = [] + price: float = 0.0 \ No newline at end of file diff --git a/hw4/app/schemas/item.py b/hw4/app/schemas/item.py new file mode 100644 index 00000000..5836ab24 --- /dev/null +++ b/hw4/app/schemas/item.py @@ -0,0 +1,28 @@ +from pydantic import BaseModel, ConfigDict + + +class CreateItem(BaseModel): + name: str + price: float + + +class Item(BaseModel): + id: int + name: str + price: float + deleted: bool = False + + +class UpdateItem(BaseModel): + id: int | None = None + name: str + price: float + deleted: bool | None = False + + +class PatchItem(BaseModel): + id: int | None = None + name: str | None = None + price: float | None = None + + model_config = ConfigDict(extra="forbid") \ No newline at end of file diff --git a/hw4/app/utils/mongo.py b/hw4/app/utils/mongo.py new file mode 100644 index 00000000..47b031aa --- /dev/null +++ b/hw4/app/utils/mongo.py @@ -0,0 +1,28 @@ +from loguru import logger + +from app.core.mongo import get_mongo + +# список коллекций, которые должны быть +REQUIRED_COLLECTIONS = [ + "carts", + "items", +] + + +async def init_mongo_collections(): + """ + Проверяет наличие базы и нужных коллекций. + Создаёт отсутствующие коллекции при запуске FastAPI. + """ + mongo = await get_mongo() + db = mongo.mongo + + # Получаем список существующих коллекций + existing_collections = await db.list_collection_names() + + for name in REQUIRED_COLLECTIONS: + if name not in existing_collections: + await db.create_collection(name) + logger.success(f"Created collection: {name}") + else: + logger.info(f"Collection '{name}' already exists") \ No newline at end of file diff --git a/hw4/docker-compose.yml b/hw4/docker-compose.yml new file mode 100644 index 00000000..164f3c0c --- /dev/null +++ b/hw4/docker-compose.yml @@ -0,0 +1,16 @@ +services: + mongo: + image: mongo:6.0 + container_name: mongo + restart: unless-stopped + environment: + MONGO_INITDB_ROOT_USERNAME: ${MONGO__USERNAME} + MONGO_INITDB_ROOT_PASSWORD: ${MONGO__PASSWORD} + ports: + - "${MONGO__PORT}:27017" + command: ["mongod", "--port", "27017"] + volumes: + - mongo_data:/data/db + +volumes: + mongo_data: \ No newline at end of file diff --git a/hw4/env.template b/hw4/env.template new file mode 100644 index 00000000..06a081dd --- /dev/null +++ b/hw4/env.template @@ -0,0 +1,6 @@ +# Mongo Settings +MONGO__URL=mongodb://admin:admin@localhost:27018/admin +MONGO__HOST=localhost +MONGO__PORT=27018 +MONGO__USERNAME=admin +MONGO__PASSWORD=admin \ No newline at end of file diff --git a/hw4/poetry.lock b/hw4/poetry.lock new file mode 100644 index 00000000..79970dc3 --- /dev/null +++ b/hw4/poetry.lock @@ -0,0 +1,569 @@ +# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand. + +[[package]] +name = "annotated-doc" +version = "0.0.3" +description = "Document parameters, class attributes, return types, and variables inline, with Annotated." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_doc-0.0.3-py3-none-any.whl", hash = "sha256:348ec6664a76f1fd3be81f43dffbee4c7e8ce931ba71ec67cc7f4ade7fbbb580"}, + {file = "annotated_doc-0.0.3.tar.gz", hash = "sha256:e18370014c70187422c33e945053ff4c286f453a984eba84d0dbfa0c935adeda"}, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +description = "Reusable constraint types to use with typing.Annotated" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, + {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, +] + +[[package]] +name = "anyio" +version = "4.11.0" +description = "High-level concurrency and networking framework on top of asyncio or Trio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc"}, + {file = "anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4"}, +] + +[package.dependencies] +idna = ">=2.8" +sniffio = ">=1.1" + +[package.extras] +trio = ["trio (>=0.31.0)"] + +[[package]] +name = "click" +version = "8.3.0" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc"}, + {file = "click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["main"] +markers = "platform_system == \"Windows\" or sys_platform == \"win32\"" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +description = "DNS toolkit" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"}, + {file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"}, +] + +[package.extras] +dev = ["black (>=25.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.17.0)", "mypy (>=1.17)", "pylint (>=3)", "pytest (>=8.4)", "pytest-cov (>=6.2.0)", "quart-trio (>=0.12.0)", "sphinx (>=8.2.0)", "sphinx-rtd-theme (>=3.0.0)", "twine (>=6.1.0)", "wheel (>=0.45.0)"] +dnssec = ["cryptography (>=45)"] +doh = ["h2 (>=4.2.0)", "httpcore (>=1.0.0)", "httpx (>=0.28.0)"] +doq = ["aioquic (>=1.2.0)"] +idna = ["idna (>=3.10)"] +trio = ["trio (>=0.30)"] +wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""] + +[[package]] +name = "fastapi" +version = "0.120.0" +description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "fastapi-0.120.0-py3-none-any.whl", hash = "sha256:84009182e530c47648da2f07eb380b44b69889a4acfd9e9035ee4605c5cfc469"}, + {file = "fastapi-0.120.0.tar.gz", hash = "sha256:6ce2c1cfb7000ac14ffd8ddb2bc12e62d023a36c20ec3710d09d8e36fab177a0"}, +] + +[package.dependencies] +annotated-doc = ">=0.0.2" +pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" +starlette = ">=0.40.0,<0.49.0" +typing-extensions = ">=4.8.0" + +[package.extras] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=3.1.5)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.18)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] +standard-no-fastapi-cloud-cli = ["email-validator (>=2.0.0)", "fastapi-cli[standard-no-fastapi-cloud-cli] (>=0.0.8)", "httpx (>=0.23.0,<1.0.0)", "jinja2 (>=3.1.5)", "python-multipart (>=0.0.18)", "uvicorn[standard] (>=0.12.0)"] + +[[package]] +name = "h11" +version = "0.16.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"}, + {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, +] + +[[package]] +name = "idna" +version = "3.11" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "loguru" +version = "0.7.3" +description = "Python logging made (stupidly) simple" +optional = false +python-versions = "<4.0,>=3.5" +groups = ["main"] +files = [ + {file = "loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c"}, + {file = "loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} +win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} + +[package.extras] +dev = ["Sphinx (==8.1.3) ; python_version >= \"3.11\"", "build (==1.2.2) ; python_version >= \"3.11\"", "colorama (==0.4.5) ; python_version < \"3.8\"", "colorama (==0.4.6) ; python_version >= \"3.8\"", "exceptiongroup (==1.1.3) ; python_version >= \"3.7\" and python_version < \"3.11\"", "freezegun (==1.1.0) ; python_version < \"3.8\"", "freezegun (==1.5.0) ; python_version >= \"3.8\"", "mypy (==v0.910) ; python_version < \"3.6\"", "mypy (==v0.971) ; python_version == \"3.6\"", "mypy (==v1.13.0) ; python_version >= \"3.8\"", "mypy (==v1.4.1) ; python_version == \"3.7\"", "myst-parser (==4.0.0) ; python_version >= \"3.11\"", "pre-commit (==4.0.1) ; python_version >= \"3.9\"", "pytest (==6.1.2) ; python_version < \"3.8\"", "pytest (==8.3.2) ; python_version >= \"3.8\"", "pytest-cov (==2.12.1) ; python_version < \"3.8\"", "pytest-cov (==5.0.0) ; python_version == \"3.8\"", "pytest-cov (==6.0.0) ; python_version >= \"3.9\"", "pytest-mypy-plugins (==1.9.3) ; python_version >= \"3.6\" and python_version < \"3.8\"", "pytest-mypy-plugins (==3.1.0) ; python_version >= \"3.8\"", "sphinx-rtd-theme (==3.0.2) ; python_version >= \"3.11\"", "tox (==3.27.1) ; python_version < \"3.8\"", "tox (==4.23.2) ; python_version >= \"3.8\"", "twine (==6.0.1) ; python_version >= \"3.11\""] + +[[package]] +name = "motor" +version = "3.7.1" +description = "Non-blocking MongoDB driver for Tornado or asyncio" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "motor-3.7.1-py3-none-any.whl", hash = "sha256:8a63b9049e38eeeb56b4fdd57c3312a6d1f25d01db717fe7d82222393c410298"}, + {file = "motor-3.7.1.tar.gz", hash = "sha256:27b4d46625c87928f331a6ca9d7c51c2f518ba0e270939d395bc1ddc89d64526"}, +] + +[package.dependencies] +pymongo = ">=4.9,<5.0" + +[package.extras] +aws = ["pymongo[aws] (>=4.5,<5)"] +docs = ["aiohttp", "furo (==2024.8.6)", "readthedocs-sphinx-search (>=0.3,<1.0)", "sphinx (>=5.3,<8)", "sphinx-rtd-theme (>=2,<3)", "tornado"] +encryption = ["pymongo[encryption] (>=4.5,<5)"] +gssapi = ["pymongo[gssapi] (>=4.5,<5)"] +ocsp = ["pymongo[ocsp] (>=4.5,<5)"] +snappy = ["pymongo[snappy] (>=4.5,<5)"] +test = ["aiohttp (>=3.8.7)", "cffi (>=1.17.0rc1) ; python_version == \"3.13\"", "mockupdb", "pymongo[encryption] (>=4.5,<5)", "pytest (>=7)", "pytest-asyncio", "tornado (>=5)"] +zstd = ["pymongo[zstd] (>=4.5,<5)"] + +[[package]] +name = "pydantic" +version = "2.12.3" +description = "Data validation using Python type hints" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic-2.12.3-py3-none-any.whl", hash = "sha256:6986454a854bc3bc6e5443e1369e06a3a456af9d339eda45510f517d9ea5c6bf"}, + {file = "pydantic-2.12.3.tar.gz", hash = "sha256:1da1c82b0fc140bb0103bc1441ffe062154c8d38491189751ee00fd8ca65ce74"}, +] + +[package.dependencies] +annotated-types = ">=0.6.0" +pydantic-core = "2.41.4" +typing-extensions = ">=4.14.1" +typing-inspection = ">=0.4.2" + +[package.extras] +email = ["email-validator (>=2.0.0)"] +timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""] + +[[package]] +name = "pydantic-core" +version = "2.41.4" +description = "Core functionality for Pydantic validation and serialization" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_core-2.41.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:2442d9a4d38f3411f22eb9dd0912b7cbf4b7d5b6c92c4173b75d3e1ccd84e36e"}, + {file = "pydantic_core-2.41.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:30a9876226dda131a741afeab2702e2d127209bde3c65a2b8133f428bc5d006b"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d55bbac04711e2980645af68b97d445cdbcce70e5216de444a6c4b6943ebcccd"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1d778fb7849a42d0ee5927ab0f7453bf9f85eef8887a546ec87db5ddb178945"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1b65077a4693a98b90ec5ad8f203ad65802a1b9b6d4a7e48066925a7e1606706"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:62637c769dee16eddb7686bf421be48dfc2fae93832c25e25bc7242e698361ba"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2dfe3aa529c8f501babf6e502936b9e8d4698502b2cfab41e17a028d91b1ac7b"}, + {file = "pydantic_core-2.41.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ca2322da745bf2eeb581fc9ea3bbb31147702163ccbcbf12a3bb630e4bf05e1d"}, + {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:e8cd3577c796be7231dcf80badcf2e0835a46665eaafd8ace124d886bab4d700"}, + {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:1cae8851e174c83633f0833e90636832857297900133705ee158cf79d40f03e6"}, + {file = "pydantic_core-2.41.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a26d950449aae348afe1ac8be5525a00ae4235309b729ad4d3399623125b43c9"}, + {file = "pydantic_core-2.41.4-cp310-cp310-win32.whl", hash = "sha256:0cf2a1f599efe57fa0051312774280ee0f650e11152325e41dfd3018ef2c1b57"}, + {file = "pydantic_core-2.41.4-cp310-cp310-win_amd64.whl", hash = "sha256:a8c2e340d7e454dc3340d3d2e8f23558ebe78c98aa8f68851b04dcb7bc37abdc"}, + {file = "pydantic_core-2.41.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:28ff11666443a1a8cf2a044d6a545ebffa8382b5f7973f22c36109205e65dc80"}, + {file = "pydantic_core-2.41.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:61760c3925d4633290292bad462e0f737b840508b4f722247d8729684f6539ae"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eae547b7315d055b0de2ec3965643b0ab82ad0106a7ffd29615ee9f266a02827"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef9ee5471edd58d1fcce1c80ffc8783a650e3e3a193fe90d52e43bb4d87bff1f"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:15dd504af121caaf2c95cb90c0ebf71603c53de98305621b94da0f967e572def"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3a926768ea49a8af4d36abd6a8968b8790f7f76dd7cbd5a4c180db2b4ac9a3a2"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6916b9b7d134bff5440098a4deb80e4cb623e68974a87883299de9124126c2a8"}, + {file = "pydantic_core-2.41.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5cf90535979089df02e6f17ffd076f07237efa55b7343d98760bde8743c4b265"}, + {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:7533c76fa647fade2d7ec75ac5cc079ab3f34879626dae5689b27790a6cf5a5c"}, + {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:37e516bca9264cbf29612539801ca3cd5d1be465f940417b002905e6ed79d38a"}, + {file = "pydantic_core-2.41.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0c19cb355224037c83642429b8ce261ae108e1c5fbf5c028bac63c77b0f8646e"}, + {file = "pydantic_core-2.41.4-cp311-cp311-win32.whl", hash = "sha256:09c2a60e55b357284b5f31f5ab275ba9f7f70b7525e18a132ec1f9160b4f1f03"}, + {file = "pydantic_core-2.41.4-cp311-cp311-win_amd64.whl", hash = "sha256:711156b6afb5cb1cb7c14a2cc2c4a8b4c717b69046f13c6b332d8a0a8f41ca3e"}, + {file = "pydantic_core-2.41.4-cp311-cp311-win_arm64.whl", hash = "sha256:6cb9cf7e761f4f8a8589a45e49ed3c0d92d1d696a45a6feaee8c904b26efc2db"}, + {file = "pydantic_core-2.41.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ab06d77e053d660a6faaf04894446df7b0a7e7aba70c2797465a0a1af00fc887"}, + {file = "pydantic_core-2.41.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c53ff33e603a9c1179a9364b0a24694f183717b2e0da2b5ad43c316c956901b2"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:304c54176af2c143bd181d82e77c15c41cbacea8872a2225dd37e6544dce9999"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:025ba34a4cf4fb32f917d5d188ab5e702223d3ba603be4d8aca2f82bede432a4"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9f5f30c402ed58f90c70e12eff65547d3ab74685ffe8283c719e6bead8ef53f"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dd96e5d15385d301733113bcaa324c8bcf111275b7675a9c6e88bfb19fc05e3b"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98f348cbb44fae6e9653c1055db7e29de67ea6a9ca03a5fa2c2e11a47cff0e47"}, + {file = "pydantic_core-2.41.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ec22626a2d14620a83ca583c6f5a4080fa3155282718b6055c2ea48d3ef35970"}, + {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:3a95d4590b1f1a43bf33ca6d647b990a88f4a3824a8c4572c708f0b45a5290ed"}, + {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:f9672ab4d398e1b602feadcffcdd3af44d5f5e6ddc15bc7d15d376d47e8e19f8"}, + {file = "pydantic_core-2.41.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:84d8854db5f55fead3b579f04bda9a36461dab0730c5d570e1526483e7bb8431"}, + {file = "pydantic_core-2.41.4-cp312-cp312-win32.whl", hash = "sha256:9be1c01adb2ecc4e464392c36d17f97e9110fbbc906bcbe1c943b5b87a74aabd"}, + {file = "pydantic_core-2.41.4-cp312-cp312-win_amd64.whl", hash = "sha256:d682cf1d22bab22a5be08539dca3d1593488a99998f9f412137bc323179067ff"}, + {file = "pydantic_core-2.41.4-cp312-cp312-win_arm64.whl", hash = "sha256:833eebfd75a26d17470b58768c1834dfc90141b7afc6eb0429c21fc5a21dcfb8"}, + {file = "pydantic_core-2.41.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:85e050ad9e5f6fe1004eec65c914332e52f429bc0ae12d6fa2092407a462c746"}, + {file = "pydantic_core-2.41.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7393f1d64792763a48924ba31d1e44c2cfbc05e3b1c2c9abb4ceeadd912cced"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94dab0940b0d1fb28bcab847adf887c66a27a40291eedf0b473be58761c9799a"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:de7c42f897e689ee6f9e93c4bec72b99ae3b32a2ade1c7e4798e690ff5246e02"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:664b3199193262277b8b3cd1e754fb07f2c6023289c815a1e1e8fb415cb247b1"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d95b253b88f7d308b1c0b417c4624f44553ba4762816f94e6986819b9c273fb2"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a1351f5bbdbbabc689727cb91649a00cb9ee7203e0a6e54e9f5ba9e22e384b84"}, + {file = "pydantic_core-2.41.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1affa4798520b148d7182da0615d648e752de4ab1a9566b7471bc803d88a062d"}, + {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:7b74e18052fea4aa8dea2fb7dbc23d15439695da6cbe6cfc1b694af1115df09d"}, + {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:285b643d75c0e30abda9dc1077395624f314a37e3c09ca402d4015ef5979f1a2"}, + {file = "pydantic_core-2.41.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:f52679ff4218d713b3b33f88c89ccbf3a5c2c12ba665fb80ccc4192b4608dbab"}, + {file = "pydantic_core-2.41.4-cp313-cp313-win32.whl", hash = "sha256:ecde6dedd6fff127c273c76821bb754d793be1024bc33314a120f83a3c69460c"}, + {file = "pydantic_core-2.41.4-cp313-cp313-win_amd64.whl", hash = "sha256:d081a1f3800f05409ed868ebb2d74ac39dd0c1ff6c035b5162356d76030736d4"}, + {file = "pydantic_core-2.41.4-cp313-cp313-win_arm64.whl", hash = "sha256:f8e49c9c364a7edcbe2a310f12733aad95b022495ef2a8d653f645e5d20c1564"}, + {file = "pydantic_core-2.41.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:ed97fd56a561f5eb5706cebe94f1ad7c13b84d98312a05546f2ad036bafe87f4"}, + {file = "pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a870c307bf1ee91fc58a9a61338ff780d01bfae45922624816878dce784095d2"}, + {file = "pydantic_core-2.41.4-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d25e97bc1f5f8f7985bdc2335ef9e73843bb561eb1fa6831fdfc295c1c2061cf"}, + {file = "pydantic_core-2.41.4-cp313-cp313t-win_amd64.whl", hash = "sha256:d405d14bea042f166512add3091c1af40437c2e7f86988f3915fabd27b1e9cd2"}, + {file = "pydantic_core-2.41.4-cp313-cp313t-win_arm64.whl", hash = "sha256:19f3684868309db5263a11bace3c45d93f6f24afa2ffe75a647583df22a2ff89"}, + {file = "pydantic_core-2.41.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:e9205d97ed08a82ebb9a307e92914bb30e18cdf6f6b12ca4bedadb1588a0bfe1"}, + {file = "pydantic_core-2.41.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:82df1f432b37d832709fbcc0e24394bba04a01b6ecf1ee87578145c19cde12ac"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc3b4cc4539e055cfa39a3763c939f9d409eb40e85813257dcd761985a108554"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b1eb1754fce47c63d2ff57fdb88c351a6c0150995890088b33767a10218eaa4e"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e6ab5ab30ef325b443f379ddb575a34969c333004fca5a1daa0133a6ffaad616"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:31a41030b1d9ca497634092b46481b937ff9397a86f9f51bd41c4767b6fc04af"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a44ac1738591472c3d020f61c6df1e4015180d6262ebd39bf2aeb52571b60f12"}, + {file = "pydantic_core-2.41.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d72f2b5e6e82ab8f94ea7d0d42f83c487dc159c5240d8f83beae684472864e2d"}, + {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c4d1e854aaf044487d31143f541f7aafe7b482ae72a022c664b2de2e466ed0ad"}, + {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b568af94267729d76e6ee5ececda4e283d07bbb28e8148bb17adad93d025d25a"}, + {file = "pydantic_core-2.41.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6d55fb8b1e8929b341cc313a81a26e0d48aa3b519c1dbaadec3a6a2b4fcad025"}, + {file = "pydantic_core-2.41.4-cp314-cp314-win32.whl", hash = "sha256:5b66584e549e2e32a1398df11da2e0a7eff45d5c2d9db9d5667c5e6ac764d77e"}, + {file = "pydantic_core-2.41.4-cp314-cp314-win_amd64.whl", hash = "sha256:557a0aab88664cc552285316809cab897716a372afaf8efdbef756f8b890e894"}, + {file = "pydantic_core-2.41.4-cp314-cp314-win_arm64.whl", hash = "sha256:3f1ea6f48a045745d0d9f325989d8abd3f1eaf47dd00485912d1a3a63c623a8d"}, + {file = "pydantic_core-2.41.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6c1fe4c5404c448b13188dd8bd2ebc2bdd7e6727fa61ff481bcc2cca894018da"}, + {file = "pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:523e7da4d43b113bf8e7b49fa4ec0c35bf4fe66b2230bfc5c13cc498f12c6c3e"}, + {file = "pydantic_core-2.41.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5729225de81fb65b70fdb1907fcf08c75d498f4a6f15af005aabb1fdadc19dfa"}, + {file = "pydantic_core-2.41.4-cp314-cp314t-win_amd64.whl", hash = "sha256:de2cfbb09e88f0f795fd90cf955858fc2c691df65b1f21f0aa00b99f3fbc661d"}, + {file = "pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0"}, + {file = "pydantic_core-2.41.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:646e76293345954acea6966149683047b7b2ace793011922208c8e9da12b0062"}, + {file = "pydantic_core-2.41.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cc8e85a63085a137d286e2791037f5fdfff0aabb8b899483ca9c496dd5797338"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:692c622c8f859a17c156492783902d8370ac7e121a611bd6fe92cc71acf9ee8d"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d1e2906efb1031a532600679b424ef1d95d9f9fb507f813951f23320903adbd7"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e04e2f7f8916ad3ddd417a7abdd295276a0bf216993d9318a5d61cc058209166"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df649916b81822543d1c8e0e1d079235f68acdc7d270c911e8425045a8cfc57e"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66c529f862fdba70558061bb936fe00ddbaaa0c647fd26e4a4356ef1d6561891"}, + {file = "pydantic_core-2.41.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3b4c5a1fd3a311563ed866c2c9b62da06cb6398bee186484ce95c820db71cb"}, + {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6e0fc40d84448f941df9b3334c4b78fe42f36e3bf631ad54c3047a0cdddc2514"}, + {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:44e7625332683b6c1c8b980461475cde9595eff94447500e80716db89b0da005"}, + {file = "pydantic_core-2.41.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:170ee6835f6c71081d031ef1c3b4dc4a12b9efa6a9540f93f95b82f3c7571ae8"}, + {file = "pydantic_core-2.41.4-cp39-cp39-win32.whl", hash = "sha256:3adf61415efa6ce977041ba9745183c0e1f637ca849773afa93833e04b163feb"}, + {file = "pydantic_core-2.41.4-cp39-cp39-win_amd64.whl", hash = "sha256:a238dd3feee263eeaeb7dc44aea4ba1364682c4f9f9467e6af5596ba322c2332"}, + {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:a1b2cfec3879afb742a7b0bcfa53e4f22ba96571c9e54d6a3afe1052d17d843b"}, + {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:d175600d975b7c244af6eb9c9041f10059f20b8bbffec9e33fdd5ee3f67cdc42"}, + {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0f184d657fa4947ae5ec9c47bd7e917730fa1cbb78195037e32dcbab50aca5ee"}, + {file = "pydantic_core-2.41.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ed810568aeffed3edc78910af32af911c835cc39ebbfacd1f0ab5dd53028e5c"}, + {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:4f5d640aeebb438517150fdeec097739614421900e4a08db4a3ef38898798537"}, + {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:4a9ab037b71927babc6d9e7fc01aea9e66dc2a4a34dff06ef0724a4049629f94"}, + {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4dab9484ec605c3016df9ad4fd4f9a390bc5d816a3b10c6550f8424bb80b18c"}, + {file = "pydantic_core-2.41.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bd8a5028425820731d8c6c098ab642d7b8b999758e24acae03ed38a66eca8335"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:1e5ab4fc177dd41536b3c32b2ea11380dd3d4619a385860621478ac2d25ceb00"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:3d88d0054d3fa11ce936184896bed3c1c5441d6fa483b498fac6a5d0dd6f64a9"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b2a054a8725f05b4b6503357e0ac1c4e8234ad3b0c2ac130d6ffc66f0e170e2"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b0d9db5a161c99375a0c68c058e227bee1d89303300802601d76a3d01f74e258"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:6273ea2c8ffdac7b7fda2653c49682db815aebf4a89243a6feccf5e36c18c347"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:4c973add636efc61de22530b2ef83a65f39b6d6f656df97f678720e20de26caa"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b69d1973354758007f46cf2d44a4f3d0933f10b6dc9bf15cf1356e037f6f731a"}, + {file = "pydantic_core-2.41.4-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:3619320641fd212aaf5997b6ca505e97540b7e16418f4a241f44cdf108ffb50d"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:491535d45cd7ad7e4a2af4a5169b0d07bebf1adfd164b0368da8aa41e19907a5"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:54d86c0cada6aba4ec4c047d0e348cbad7063b87ae0f005d9f8c9ad04d4a92a2"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eca1124aced216b2500dc2609eade086d718e8249cb9696660ab447d50a758bd"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c9024169becccf0cb470ada03ee578d7348c119a0d42af3dcf9eda96e3a247c"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:26895a4268ae5a2849269f4991cdc97236e4b9c010e51137becf25182daac405"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:ca4df25762cf71308c446e33c9b1fdca2923a3f13de616e2a949f38bf21ff5a8"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5a28fcedd762349519276c36634e71853b4541079cab4acaaac60c4421827308"}, + {file = "pydantic_core-2.41.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c173ddcd86afd2535e2b695217e82191580663a1d1928239f877f5a1649ef39f"}, + {file = "pydantic_core-2.41.4.tar.gz", hash = "sha256:70e47929a9d4a1905a67e4b687d5946026390568a8e952b92824118063cee4d5"}, +] + +[package.dependencies] +typing-extensions = ">=4.14.1" + +[[package]] +name = "pydantic-settings" +version = "2.11.0" +description = "Settings management using Pydantic" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pydantic_settings-2.11.0-py3-none-any.whl", hash = "sha256:fe2cea3413b9530d10f3a5875adffb17ada5c1e1bab0b2885546d7310415207c"}, + {file = "pydantic_settings-2.11.0.tar.gz", hash = "sha256:d0e87a1c7d33593beb7194adb8470fc426e95ba02af83a0f23474a04c9a08180"}, +] + +[package.dependencies] +pydantic = ">=2.7.0" +python-dotenv = ">=0.21.0" +typing-inspection = ">=0.4.0" + +[package.extras] +aws-secrets-manager = ["boto3 (>=1.35.0)", "boto3-stubs[secretsmanager]"] +azure-key-vault = ["azure-identity (>=1.16.0)", "azure-keyvault-secrets (>=4.8.0)"] +gcp-secret-manager = ["google-cloud-secret-manager (>=2.23.1)"] +toml = ["tomli (>=2.0.1)"] +yaml = ["pyyaml (>=6.0.1)"] + +[[package]] +name = "pymongo" +version = "4.15.3" +description = "PyMongo - the Official MongoDB Python driver" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pymongo-4.15.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:482ca9b775747562ce1589df10c97a0e62a604ce5addf933e5819dd967c5e23c"}, + {file = "pymongo-4.15.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c7eb497519f42ac89c30919a51f80e68a070cfc2f3b0543cac74833cd45a6b9c"}, + {file = "pymongo-4.15.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4a0a054e9937ec8fdb465835509b176f6b032851c8648f6a5d1b19932d0eacd6"}, + {file = "pymongo-4.15.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49fd6e158cf75771b2685a8a221a40ab96010ae34dd116abd06371dc6c38ab60"}, + {file = "pymongo-4.15.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82a490f1ade4ec6a72068e3676b04c126e3043e69b38ec474a87c6444cf79098"}, + {file = "pymongo-4.15.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:982107c667921e896292f4be09c057e2f1a40c645c9bfc724af5dd5fb8398094"}, + {file = "pymongo-4.15.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:45aebbd369ca79b7c46eaea5b04d2e4afca4eda117b68965a07a9da05d774e4d"}, + {file = "pymongo-4.15.3-cp310-cp310-win32.whl", hash = "sha256:90ad56bd1d769d2f44af74f0fd0c276512361644a3c636350447994412cbc9a1"}, + {file = "pymongo-4.15.3-cp310-cp310-win_amd64.whl", hash = "sha256:8bd6dd736f5d07a825caf52c38916d5452edc0fac7aee43ec67aba6f61c2dbb7"}, + {file = "pymongo-4.15.3-cp310-cp310-win_arm64.whl", hash = "sha256:300eaf83ad053e51966be1839324341b08eaf880d3dc63ada7942d5912e09c49"}, + {file = "pymongo-4.15.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a13d8f7141294404ce46dfbabb2f2d17e9b1192456651ae831fa351f86fbeb"}, + {file = "pymongo-4.15.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:17d13458baf4a6a9f2e787d95adf8ec50d412accb9926a044bd1c41029c323b2"}, + {file = "pymongo-4.15.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fe4bcb8acfb288e238190397d4a699aeb4adb70e8545a6f4e44f99d4e8096ab1"}, + {file = "pymongo-4.15.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d09d895c7f08bcbed4d2e96a00e52e9e545ae5a37b32d2dc10099b205a21fc6d"}, + {file = "pymongo-4.15.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:21c0a95a4db72562fd0805e2f76496bf432ba2e27a5651f4b9c670466260c258"}, + {file = "pymongo-4.15.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89e45d7fa987f4e246cdf43ff001e3f911f73eb19ba9dabc2a6d80df5c97883b"}, + {file = "pymongo-4.15.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1246a82fa6dd73ac2c63aa7e463752d5d1ca91e0c7a23396b78f21273befd3a7"}, + {file = "pymongo-4.15.3-cp311-cp311-win32.whl", hash = "sha256:9483521c03f6017336f54445652ead3145154e8d3ea06418e52cea57fee43292"}, + {file = "pymongo-4.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:c57dad9f289d72af1d7c47a444c4d9fa401f951cedbbcc54c7dd0c2107d6d786"}, + {file = "pymongo-4.15.3-cp311-cp311-win_arm64.whl", hash = "sha256:2fd3b99520f2bb013960ac29dece1b43f2f1b6d94351ca33ba1b1211ecf79a09"}, + {file = "pymongo-4.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bd0497c564b0ae34fb816464ffc09986dd9ca29e2772a0f7af989e472fecc2ad"}, + {file = "pymongo-4.15.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:292fd5a3f045751a823a54cdea75809b2216a62cc5f74a1a96b337db613d46a8"}, + {file = "pymongo-4.15.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:959ef69c5e687b6b749fbf2140c7062abdb4804df013ae0507caabf30cba6875"}, + {file = "pymongo-4.15.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de3bc878c3be54ae41c2cabc9e9407549ed4fec41f4e279c04e840dddd7c630c"}, + {file = "pymongo-4.15.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07bcc36d11252f24fe671e7e64044d39a13d997b0502c6401161f28cc144f584"}, + {file = "pymongo-4.15.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b63bac343b79bd209e830aac1f5d9d552ff415f23a924d3e51abbe3041265436"}, + {file = "pymongo-4.15.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b33d59bf6fa1ca1d7d96d4fccff51e41312358194190d53ef70a84c070f5287e"}, + {file = "pymongo-4.15.3-cp312-cp312-win32.whl", hash = "sha256:b3a0ec660d61efb91c16a5962ec937011fe3572c4338216831f102e53d294e5c"}, + {file = "pymongo-4.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:f6b0513e5765fdde39f36e6a29a36c67071122b5efa748940ae51075beb5e4bc"}, + {file = "pymongo-4.15.3-cp312-cp312-win_arm64.whl", hash = "sha256:c4fdd8e6eab8ff77c1c8041792b5f760d48508623cd10b50d5639e73f1eec049"}, + {file = "pymongo-4.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a47a3218f7900f65bf0f36fcd1f2485af4945757360e7e143525db9d715d2010"}, + {file = "pymongo-4.15.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:09440e78dff397b2f34a624f445ac8eb44c9756a2688b85b3bf344d351d198e1"}, + {file = "pymongo-4.15.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:97f9babdb98c31676f97d468f7fe2dc49b8a66fb6900effddc4904c1450196c8"}, + {file = "pymongo-4.15.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71413cd8f091ae25b1fec3af7c2e531cf9bdb88ce4079470e64835f6a664282a"}, + {file = "pymongo-4.15.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:76a8d4de8dceb69f6e06736198ff6f7e1149515ef946f192ff2594d2cc98fc53"}, + {file = "pymongo-4.15.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:77353978be9fc9e5fe56369682efed0aac5f92a2a1570704d62b62a3c9e1a24f"}, + {file = "pymongo-4.15.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9897a837677e3814873d0572f7e5d53c23ce18e274f3b5b87f05fb6eea22615b"}, + {file = "pymongo-4.15.3-cp313-cp313-win32.whl", hash = "sha256:d66da207ccb0d68c5792eaaac984a0d9c6c8ec609c6bcfa11193a35200dc5992"}, + {file = "pymongo-4.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:52f40c4b8c00bc53d4e357fe0de13d031c4cddb5d201e1a027db437e8d2887f8"}, + {file = "pymongo-4.15.3-cp313-cp313-win_arm64.whl", hash = "sha256:fb384623ece34db78d445dd578a52d28b74e8319f4d9535fbaff79d0eae82b3d"}, + {file = "pymongo-4.15.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dcff15b9157c16bc796765d4d3d151df669322acfb0357e4c3ccd056153f0ff4"}, + {file = "pymongo-4.15.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1f681722c9f27e86c49c2e8a838e61b6ecf2285945fd1798bd01458134257834"}, + {file = "pymongo-4.15.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2c96dde79bdccd167b930a709875b0cd4321ac32641a490aebfa10bdcd0aa99b"}, + {file = "pymongo-4.15.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d2d4ca446348d850ac4a5c3dc603485640ae2e7805dbb90765c3ba7d79129b37"}, + {file = "pymongo-4.15.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0fd3de3a12ff0a8113a3f64cedb01f87397ab8eaaffa88d7f18ca66cd39385"}, + {file = "pymongo-4.15.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e84dec392cf5f72d365e0aac73f627b0a3170193ebb038c3f7e7df11b7983ee7"}, + {file = "pymongo-4.15.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8d4b01a48369ea6d5bc83fea535f56279f806aa3e4991189f0477696dd736289"}, + {file = "pymongo-4.15.3-cp314-cp314-win32.whl", hash = "sha256:3561fa96c3123275ec5ccf919e595547e100c412ec0894e954aa0da93ecfdb9e"}, + {file = "pymongo-4.15.3-cp314-cp314-win_amd64.whl", hash = "sha256:9df2db6bd91b07400879b6ec89827004c0c2b55fc606bb62db93cafb7677c340"}, + {file = "pymongo-4.15.3-cp314-cp314-win_arm64.whl", hash = "sha256:ff99864085d2c7f4bb672c7167680ceb7d273e9a93c1a8074c986a36dbb71cc6"}, + {file = "pymongo-4.15.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ffe217d2502f3fba4e2b0dc015ce3b34f157b66dfe96835aa64432e909dd0d95"}, + {file = "pymongo-4.15.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:390c4954c774eda280898e73aea36482bf20cba3ecb958dbb86d6a68b9ecdd68"}, + {file = "pymongo-4.15.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7dd2a49f088890ca08930bbf96121443b48e26b02b84ba0a3e1ae2bf2c5a9b48"}, + {file = "pymongo-4.15.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f6feb678f26171f2a6b2cbb340949889154c7067972bd4cc129b62161474f08"}, + {file = "pymongo-4.15.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:446417a34ff6c2411ce3809e17ce9a67269c9f1cb4966b01e49e0c590cc3c6b3"}, + {file = "pymongo-4.15.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cfa4a0a0f024a0336640e1201994e780a17bda5e6a7c0b4d23841eb9152e868b"}, + {file = "pymongo-4.15.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b03db2fe37c950aff94b29ded5c349b23729bccd90a0a5907bbf807d8c77298"}, + {file = "pymongo-4.15.3-cp314-cp314t-win32.whl", hash = "sha256:e7cde58ef6470c0da922b65e885fb1ffe04deef81e526bd5dea429290fa358ca"}, + {file = "pymongo-4.15.3-cp314-cp314t-win_amd64.whl", hash = "sha256:fae552767d8e5153ed498f1bca92d905d0d46311d831eefb0f06de38f7695c95"}, + {file = "pymongo-4.15.3-cp314-cp314t-win_arm64.whl", hash = "sha256:47ffb068e16ae5e43580d5c4e3b9437f05414ea80c32a1e5cac44a835859c259"}, + {file = "pymongo-4.15.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:58d0f4123855f05c0649f9b8ee083acc5b26e7f4afde137cd7b8dc03e9107ff3"}, + {file = "pymongo-4.15.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9bc9f99e7702fdb0dcc3ff1dd490adc5d20b3941ad41e58f887d4998b9922a14"}, + {file = "pymongo-4.15.3-cp39-cp39-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:86b1b5b63f4355adffc329733733a9b71fdad88f37a9dc41e163aed2130f9abc"}, + {file = "pymongo-4.15.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a054d282dd922ac400b6f47ea3ef58d8b940968d76d855da831dc739b7a04de"}, + {file = "pymongo-4.15.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc583a1130e2516440b93bb2ecb55cfdac6d5373615ae472a9d1f26801f58749"}, + {file = "pymongo-4.15.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c78237e878e0296130e398151b0d4aa6c9eaf82e38fb6e0aaae2029bc7ef0ce"}, + {file = "pymongo-4.15.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5c85a4c72b7965033f95c94c42dac27d886c01dbc23fe337ccb14f052a0ccc29"}, + {file = "pymongo-4.15.3-cp39-cp39-win32.whl", hash = "sha256:17fc94d1e067556b122eeb09e25c003268e8c0ea1f2f78e745b33bb59a1209c4"}, + {file = "pymongo-4.15.3-cp39-cp39-win_amd64.whl", hash = "sha256:5bf879a6ed70264574d4d8fb5a467c2a64dc76ecd72c0cb467c4464f849c8c77"}, + {file = "pymongo-4.15.3-cp39-cp39-win_arm64.whl", hash = "sha256:2f3d66f7c495efc3cfffa611b36075efe86da1860a7df75522a6fe499ee10383"}, + {file = "pymongo-4.15.3.tar.gz", hash = "sha256:7a981271347623b5319932796690c2d301668ac3a1965974ac9f5c3b8a22cea5"}, +] + +[package.dependencies] +dnspython = ">=1.16.0,<3.0.0" + +[package.extras] +aws = ["pymongo-auth-aws (>=1.1.0,<2.0.0)"] +docs = ["furo (==2025.7.19)", "readthedocs-sphinx-search (>=0.3,<1.0)", "sphinx (>=5.3,<9)", "sphinx-autobuild (>=2020.9.1)", "sphinx-rtd-theme (>=2,<4)", "sphinxcontrib-shellcheck (>=1,<2)"] +encryption = ["certifi ; os_name == \"nt\" or sys_platform == \"darwin\"", "pymongo-auth-aws (>=1.1.0,<2.0.0)", "pymongocrypt (>=1.13.0,<2.0.0)"] +gssapi = ["pykerberos ; os_name != \"nt\"", "winkerberos (>=0.5.0) ; os_name == \"nt\""] +ocsp = ["certifi ; os_name == \"nt\" or sys_platform == \"darwin\"", "cryptography (>=2.5)", "pyopenssl (>=17.2.0)", "requests (<3.0.0)", "service-identity (>=18.1.0)"] +snappy = ["python-snappy"] +test = ["pytest (>=8.2)", "pytest-asyncio (>=0.24.0)"] +zstd = ["zstandard"] + +[[package]] +name = "python-dotenv" +version = "1.1.1" +description = "Read key-value pairs from a .env file and set them as environment variables" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc"}, + {file = "python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab"}, +] + +[package.extras] +cli = ["click (>=5.0)"] + +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + +[[package]] +name = "starlette" +version = "0.48.0" +description = "The little ASGI library that shines." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "starlette-0.48.0-py3-none-any.whl", hash = "sha256:0764ca97b097582558ecb498132ed0c7d942f233f365b86ba37770e026510659"}, + {file = "starlette-0.48.0.tar.gz", hash = "sha256:7e8cee469a8ab2352911528110ce9088fdc6a37d9876926e73da7ce4aa4c7a46"}, +] + +[package.dependencies] +anyio = ">=3.6.2,<5" + +[package.extras] +full = ["httpx (>=0.27.0,<0.29.0)", "itsdangerous", "jinja2", "python-multipart (>=0.0.18)", "pyyaml"] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +description = "Runtime typing introspection tools" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"}, + {file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"}, +] + +[package.dependencies] +typing-extensions = ">=4.12.0" + +[[package]] +name = "uvicorn" +version = "0.38.0" +description = "The lightning-fast ASGI server." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02"}, + {file = "uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d"}, +] + +[package.dependencies] +click = ">=7.0" +h11 = ">=0.8" + +[package.extras] +standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.13)", "websockets (>=10.4)"] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +description = "A small Python utility to set file creation time on Windows" +optional = false +python-versions = ">=3.5" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"}, + {file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"}, +] + +[package.extras] +dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.14,<4.0" +content-hash = "6bc9d7f3bd6579bd92361957235effcd7919de1ac2f5837e4b75fff0e9edaba6" \ No newline at end of file diff --git a/hw4/project.toml b/hw4/project.toml new file mode 100644 index 00000000..2db9a30f --- /dev/null +++ b/hw4/project.toml @@ -0,0 +1,21 @@ +[project] +name = "fastapi-template" +version = "0.1.0" +description = "" +authors = [{ name = "Nikita Aspaev", email = "asspaev@yandex.ru" }] +readme = "README.md" +requires-python = ">=3.14,<4.0" +dependencies = [ + "fastapi (>=0.120.0,<0.121.0)", + "uvicorn (>=0.38.0,<0.39.0)", + "pydantic (>=2.12.3,<3.0.0)", + "pydantic-settings (>=2.11.0,<3.0.0)", + "loguru (>=0.7.3,<0.8.0)", + "motor (>=3.7.1,<4.0.0)" +] + + + +[build-system] +requires = ["poetry-core>=2.0.0,<3.0.0"] +build-backend = "poetry.core.masonry.api" \ No newline at end of file