diff --git a/hw1/app.py b/hw1/app.py index 6107b870..bbf6eef6 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,107 @@ from typing import Any, Awaitable, Callable +import json +import urllib.parse + + +async def handle_factorial(query_string: str) -> tuple[int, dict[str, Any]]: + """Обрабатывает запрос /factorial?n=.""" + status = 200 + response_body = {"result": None} + + if not query_string: + status = 422 + response_body = {"error": "Missing parameter 'n'"} + else: + params = urllib.parse.parse_qs(query_string) + if "n" not in params or len(params["n"]) != 1: + status = 422 + response_body = {"error": "Invalid parameter 'n'"} + else: + try: + n = int(params["n"][0]) + if n < 0: + status = 400 + response_body = {"error": "Parameter 'n' must be non-negative"} + else: + # Вычисляем факториал + result = 1 + for i in range(1, n + 1): + result *= i + response_body = {"result": result} + except ValueError: + status = 422 + response_body = {"error": "Parameter 'n' must be an integer"} + + return status, response_body + + +async def handle_fibonacci(path: str) -> tuple[int, dict[str, Any]]: + """Обрабатывает запрос /fibonacci/.""" + status = 200 + response_body = {"result": None} + + try: + n = int(path.split("/")[-1]) + if n < 0: + status = 400 + response_body = {"error": "Parameter must be non-negative"} + else: + # Вычисляем число Фибоначчи + if n == 0: + result = 0 + elif n == 1: + result = 1 + else: + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + result = b + response_body = {"result": result} + except ValueError: + status = 422 + response_body = {"error": "Parameter must be an integer"} + + return status, response_body + + +async def handle_mean( + receive: Callable[[], Awaitable[dict[str, Any]]], +) -> tuple[int, dict[str, Any]]: + """Обрабатывает запрос /mean с JSON в теле запроса.""" + status = 200 + response_body = {"result": None} + + message = await receive() + if message["type"] != "http.request": + status = 422 + response_body = {"error": "Invalid request format"} + else: + body = message.get("body", b"") + if not body: + status = 422 + response_body = {"error": "Missing numbers"} + else: + try: + numbers = json.loads(body) + if not isinstance(numbers, list): + status = 422 + response_body = {"error": "Input must be a list of numbers"} + elif not numbers: + status = 400 + response_body = {"error": "List of numbers cannot be empty"} + else: + try: + numbers = [float(x) for x in numbers] + result = sum(numbers) / len(numbers) + response_body = {"result": result} + except (ValueError, TypeError): + status = 422 + response_body = {"error": "All elements must be numbers"} + except json.JSONDecodeError: + status = 422 + response_body = {"error": "Invalid JSON format"} + + return status, response_body async def application( @@ -12,8 +115,75 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + # Обработка lifespan-сообщений + 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"}) + return + + # Проверяем, что это HTTP-запрос + if scope["type"] != "http": + return + + # Получаем метод, путь и параметры запроса + method = scope["method"] + path = scope["path"] + query_string = scope["query_string"].decode("utf-8") + + # Инициализация ответа + status = 200 + response_body = {"result": None} + + try: + # Обработка несуществующих эндпоинтов и неподдерживаемых методов + if method != "GET" or ( + not path.startswith("/factorial") + and not path.startswith("/fibonacci") + and not path.startswith("/mean") + ): + status = 404 + response_body = {"error": "Not found"} + else: + if path.startswith("/factorial"): + status, response_body = await handle_factorial(query_string) + elif path.startswith("/fibonacci"): + status, response_body = await handle_fibonacci(path) + elif path.startswith("/mean"): + status, response_body = await handle_mean(receive) + + except Exception: + status = 422 + response_body = {"error": "Invalid request format"} + + # Подготовка ответа + response_bytes = json.dumps(response_body).encode("utf-8") + + # Отправка заголовков + await send( + { + "type": "http.response.start", + "status": status, + "headers": [ + [b"content-type", b"application/json"], + [b"content-length", str(len(response_bytes)).encode("utf-8")], + ], + } + ) + + # Отправка тела ответа + await send( + { + "type": "http.response.body", + "body": response_bytes, + } + ) + if __name__ == "__main__": import uvicorn + uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..8b8e274b 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -7,3 +7,6 @@ pytest>=7.4.0 pytest-asyncio>=0.21.0 httpx>=0.27.2 Faker>=37.8.0 + +websocket-client==1.8.0 +websockets==15.0.1 \ No newline at end of file diff --git a/hw2/hw/shop_api/client.py b/hw2/hw/shop_api/client.py new file mode 100644 index 00000000..283b0c15 --- /dev/null +++ b/hw2/hw/shop_api/client.py @@ -0,0 +1,49 @@ +import sys +import threading +import time + +from websocket import create_connection + +# Проверяем, что пользователь указал имя комнаты +if len(sys.argv) < 2: + print("Usage: python client.py ") + sys.exit(1) + +chat_name = sys.argv[1] + +# Подключаемся к серверу по WebSocket +ws = create_connection(f"ws://localhost:8000/chat/{chat_name}") +print(f"Connected to chat room: {chat_name}") + + +def receive_messages(): + """Слушаем сообщения от сервера в отдельном потоке""" + while True: + try: + response = ws.recv() # Получаю сообщение + print(response) + except Exception as e: + print(f"Connection closed: {e}") + break + + +# Запуск потока для получения сообщений +thread = threading.Thread(target=receive_messages) +thread.daemon = True # Поток завершится, когда программа закончится +thread.start() + +# цикл для ввода сообщений +while True: + try: + message = input("Enter message: ") + if message.strip(): # Отправка только непустые сообщения + ws.send(message) + time.sleep(0.1) + except KeyboardInterrupt: + ws.close() # закрытие соединение при Ctrl+C + print("Disconnected from chat") + break + except Exception as e: + print(f"Error: {e}") + ws.close() + break diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..239fbeba 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,349 @@ -from fastapi import FastAPI +from dataclasses import dataclass, field +from http import HTTPStatus +from typing import Any, Dict, List, Optional +from uuid import uuid4 + +from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect +from fastapi.responses import JSONResponse + +from shop_api.models import (CartItem, CartResponse, ItemCreate, ItemPatch, + ItemResponse, ItemUpdate) app = FastAPI(title="Shop API") + +# Хранение данных в памяти: словари для быстрого поиска по ID +carts: Dict[int, Dict[str, Any]] = ( + {} +) # {cart_id: {"id": int, "items": [{"id": int, "quantity": int}]}} +items: Dict[int, Dict[str, Any]] = ( + {} +) # {item_id: {"id": int, "name": str, "price": float, "deleted": bool}} +cart_id_counter: int = 0 # Счётчик для уникальных ID корзин +item_id_counter: int = 0 # Счётчик для уникальных ID товаров + + +# Вспомогательные функции +def get_next_cart_id() -> int: + """Генерация уникального ID для корзины""" + global cart_id_counter + cart_id_counter += 1 + return cart_id_counter + + +def get_next_item_id() -> int: + """Генерация уникального ID для товара""" + global item_id_counter + item_id_counter += 1 + return item_id_counter + + +def calculate_cart_price(cart: Dict[str, Any]) -> float: + """ + Расчёт общей цены корзины: + Сумма (price * quantity) только для доступных товаров (не удалённых) + """ + price = 0.0 + for cart_item in cart.get("items", []): + item_id = cart_item["id"] + if item_id in items and not items[item_id]["deleted"]: + price += items[item_id]["price"] * cart_item["quantity"] + return price + + +# Эндпоинты для корзин (Cart) +@app.post("/cart", status_code=HTTPStatus.CREATED, response_model=Dict[str, int]) +async def create_cart() -> JSONResponse: + """Создание новой пустой корзины, возвращает ID""" + cart_id = get_next_cart_id() + carts[cart_id] = {"id": cart_id, "items": []} + return JSONResponse( + content={"id": cart_id}, + status_code=HTTPStatus.CREATED, + headers={"location": f"/cart/{cart_id}"}, + ) + + +@app.get("/cart/{id}", response_model=CartResponse) +async def get_cart_by_id(id: int) -> Dict[str, Any]: + """Получение корзины по ID""" + if id not in carts: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Корзина не найдена" + ) + + cart = carts[id] + cart_items = [] + for cart_item in cart["items"]: + item_id = cart_item["id"] + if item_id in items: + cart_items.append( + { + "id": item_id, + "name": items[item_id]["name"], + "quantity": cart_item["quantity"], + "available": not items[item_id]["deleted"], + } + ) + + return {"id": cart["id"], "items": cart_items, "price": calculate_cart_price(cart)} + + +@app.get("/cart", response_model=List[CartResponse]) +async def list_carts( + offset: int = 0, + limit: int = 10, + min_price: Optional[float] = None, + max_price: Optional[float] = None, + min_quantity: Optional[int] = None, + max_quantity: Optional[int] = None, +) -> List[Dict[str, Any]]: + """ + Список корзин с фильтрами и пагинацией. + Валидация: offset >= 0, limit > 0, цены/кол-ва >= 0 + """ + if offset < 0 or limit <= 0: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail="Неверный offset или limit", + ) + if min_price is not None and min_price < 0: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail="Неверный min_price" + ) + if max_price is not None and max_price < 0: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail="Неверный max_price" + ) + if min_quantity is not None and min_quantity < 0: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail="Неверный min_quantity" + ) + if max_quantity is not None and max_quantity < 0: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail="Неверный max_quantity" + ) + + result = [] + for cart_id in sorted(carts.keys()): + cart_response = await get_cart_by_id(cart_id) + total_quantity = sum(item["quantity"] for item in cart_response["items"]) + cart_price = cart_response["price"] + + if min_price is not None and cart_price < min_price: + continue + if max_price is not None and cart_price > max_price: + continue + if min_quantity is not None and total_quantity < min_quantity: + continue + if max_quantity is not None and total_quantity > max_quantity: + continue + + result.append(cart_response) + + return result[offset : offset + limit] + + +@app.post("/cart/{cart_id}/add/{item_id}") +async def add_item_to_cart(cart_id: int, item_id: int) -> None: + """Добавление товара в корзину: инкремент, если есть; добавление, если нет""" + if cart_id not in carts: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, detail="Корзина не найдена" + ) + if item_id not in items: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Товар не найден") + + cart = carts[cart_id] + for cart_item in cart["items"]: + if cart_item["id"] == item_id: + cart_item["quantity"] += 1 + return + cart["items"].append({"id": item_id, "quantity": 1}) + + +# Эндпоинты для товаров (Item) +@app.post("/item", response_model=ItemResponse, status_code=HTTPStatus.CREATED) +async def create_item(item: ItemCreate) -> Dict[str, Any]: + """Создание нового товара""" + item_id = get_next_item_id() + new_item = {"id": item_id, "name": item.name, "price": item.price, "deleted": False} + items[item_id] = new_item + return new_item + + +@app.get("/item/{id}", response_model=ItemResponse) +async def get_item_by_id(id: int) -> Dict[str, Any]: + """Получение товара по ID (404, если удалён)""" + if id not in items or items[id]["deleted"]: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Товар не найден") + return items[id] + + +@app.get("/item", response_model=List[ItemResponse]) +async def list_items( + offset: int = 0, + limit: int = 10, + min_price: Optional[float] = None, + max_price: Optional[float] = None, + show_deleted: bool = False, +) -> List[Dict[str, Any]]: + """ + Список товаров с фильтрами и пагинацией. + По умолчанию не показывает удалённые. + """ + if offset < 0 or limit <= 0: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, + detail="Неверный offset или limit", + ) + if min_price is not None and min_price < 0: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail="Неверный min_price" + ) + if max_price is not None and max_price < 0: + raise HTTPException( + status_code=HTTPStatus.UNPROCESSABLE_ENTITY, detail="Неверный max_price" + ) + + result = [] + for item_id in sorted(items.keys()): + item = items[item_id] + if not show_deleted and item["deleted"]: + continue + if min_price is not None and item["price"] < min_price: + continue + if max_price is not None and item["price"] > max_price: + continue + result.append(item) + + return result[offset : offset + limit] + + +@app.put("/item/{id}", response_model=ItemResponse) +async def update_item(id: int, item: ItemUpdate) -> Dict[str, Any]: + """Полная замена товара (304, если удалён)""" + if id not in items: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Товар не найден") + if items[id]["deleted"]: + raise HTTPException(status_code=HTTPStatus.NOT_MODIFIED, detail="Товар удалён") + + updated_item = { + "id": id, + "name": item.name, + "price": item.price, + "deleted": items[id]["deleted"], + } + items[id] = updated_item + return updated_item + + +@app.patch("/item/{id}", response_model=ItemResponse) +async def patch_item(id: int, patch_data: ItemPatch) -> Dict[str, Any]: + """ + Частичное обновление (не трогает deleted). + 422 на попытку изменить deleted или лишние поля. + """ + if id not in items: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Товар не найден") + if items[id]["deleted"]: + raise HTTPException(status_code=HTTPStatus.NOT_MODIFIED, detail="Товар удалён") + + updated_item = items[id].copy() + if patch_data.name is not None: + updated_item["name"] = patch_data.name + if patch_data.price is not None: + updated_item["price"] = patch_data.price + + items[id] = updated_item + return updated_item + + +@app.delete("/item/{id}", status_code=HTTPStatus.OK) +async def delete_item(id: int) -> None: + """Soft-delete: помечает как удалённый (идемпотентно)""" + if id not in items: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Товар не найден") + items[id]["deleted"] = True + + +# WebSocket чат +@dataclass(slots=True) +class Broadcaster: + """Класс для broadcast в одной комнате""" + + subscribers: list[WebSocket] = field(init=False, default_factory=list) + usernames: Dict[WebSocket, str] = field( + init=False, default_factory=dict + ) # {ws: username} + + async def subscribe(self, ws: WebSocket) -> str: + """Подписка клиента, присвоение имени""" + await ws.accept() + self.subscribers.append(ws) + username = f"User_{uuid4().hex[:4]}" # случайное имя типа User_a1b2 + self.usernames[ws] = username + return username + + async def unsubscribe(self, ws: WebSocket) -> str: + """Отписка клиента""" + if ws in self.subscribers: + self.subscribers.remove(ws) + username = self.usernames.pop(ws, "unknown") + return username + return "unknown" + + async def publish( + self, message: str, exclude_ws: Optional[WebSocket] = None + ) -> None: + """Broadcast сообщения в комнату, исключая exclude_ws""" + dead_ws = [] + for ws in self.subscribers: + if ws == exclude_ws: + continue + try: + await ws.send_text(message) + except Exception: + dead_ws.append(ws) + # Удаляем мертвые соединения + for dead in dead_ws: + await self.unsubscribe(dead) + + +# Хранение комнат для чата (stateful, в памяти) +chat_rooms: Dict[str, Broadcaster] = {} # {chat_name: Broadcaster} + + +@app.websocket("/chat/{chat_name}") +async def ws_chat(ws: WebSocket, chat_name: str): + """Подключение к чату в комнате {chat_name}""" + # Получаем или создаём broadcaster для комнаты + if chat_name not in chat_rooms: + chat_rooms[chat_name] = Broadcaster() + broadcaster = chat_rooms[chat_name] + + # Подписка и отправка приветствия (всем, кроме самого) + username = await broadcaster.subscribe(ws) + await broadcaster.publish(f"{username} :: joined the chat", exclude_ws=ws) + + try: + while True: + # Получаем сообщение от клиента + message = await ws.receive_text() + message = message.strip() + if not message: # Пропускаем пустые сообщения + continue + # Форматируем и broadcast (исключая отправителя) + formatted_message = f"{username} :: {message}" + await broadcaster.publish(formatted_message, exclude_ws=ws) + except WebSocketDisconnect: + # Отписка и уведомление (всем) + username = await broadcaster.unsubscribe(ws) + await broadcaster.publish(f"{username} :: left the chat") + except Exception as e: + # Обработка других ошибок (для стабильности) + await broadcaster.unsubscribe(ws) + await broadcaster.publish(f"{username} :: disconnected unexpectedly") + finally: + # Если комната пустая — удаляем + if not broadcaster.subscribers: + del chat_rooms[chat_name] diff --git a/hw2/hw/shop_api/models.py b/hw2/hw/shop_api/models.py new file mode 100644 index 00000000..54814529 --- /dev/null +++ b/hw2/hw/shop_api/models.py @@ -0,0 +1,52 @@ +from typing import List, Optional + +from pydantic import BaseModel, ConfigDict, Field + + +# Pydantic-модели для валидации запросов/ответов +class ItemCreate(BaseModel): + """Модель для создания товара""" + + name: str # Название товара + price: float = Field(..., gt=0) # Цена > 0 + + +class ItemUpdate(BaseModel): + """Модель для полной замены товара""" + + name: str + price: float = Field(..., gt=0) + + +class ItemPatch(BaseModel): + """Модель для частичного обновления товара""" + + name: Optional[str] = None + price: Optional[float] = Field(None, gt=0) + model_config = ConfigDict(extra="forbid") # Запрещаем лишние поля + + +class ItemResponse(BaseModel): + """Ответ для товара""" + + id: int + name: str + price: float + deleted: bool + + +class CartItem(BaseModel): + """Товар в корзине""" + + id: int + name: str + quantity: int + available: bool # Доступен ли (не удалён) + + +class CartResponse(BaseModel): + """Ответ для корзины""" + + id: int + items: List[CartItem] + price: float # Общая цена