From fa34f5ea29bfe966b6d2c46bf58f877f7a0783c5 Mon Sep 17 00:00:00 2001 From: devspark993 Date: Fri, 26 Sep 2025 23:40:33 +0300 Subject: [PATCH 1/6] Implement ASGI application with fibonacci, factorial, and mean endpoints --- hw1/app.py | 141 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 1 deletion(-) diff --git a/hw1/app.py b/hw1/app.py index 6107b870..ade9665b 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,6 @@ from typing import Any, Awaitable, Callable +import json +import urllib.parse async def application( @@ -12,7 +14,144 @@ 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"): + # Обработка /factorial?n= + 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"} + + elif path.startswith("/fibonacci"): + # Обработка /fibonacci/ + 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"} + + elif path.startswith("/mean"): + # Обработка /mean с JSON в теле запроса + 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"} + + 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 From cb7edef85d34d6b9542acf430d7cf38294272035 Mon Sep 17 00:00:00 2001 From: devspark993 Date: Sat, 27 Sep 2025 02:31:25 +0300 Subject: [PATCH 2/6] HW 1 --- hw1/app.py | 49 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 32 insertions(+), 17 deletions(-) diff --git a/hw1/app.py b/hw1/app.py index ade9665b..8dce1adb 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -63,7 +63,9 @@ async def application( n = int(params["n"][0]) if n < 0: status = 400 - response_body = {"error": "Parameter 'n' must be non-negative"} + response_body = { + "error": "Parameter 'n' must be non-negative" + } else: # Вычисляем факториал result = 1 @@ -72,7 +74,9 @@ async def application( response_body = {"result": result} except ValueError: status = 422 - response_body = {"error": "Parameter 'n' must be an integer"} + response_body = { + "error": "Parameter 'n' must be an integer" + } elif path.startswith("/fibonacci"): # Обработка /fibonacci/ @@ -113,10 +117,14 @@ async def application( numbers = json.loads(body) if not isinstance(numbers, list): status = 422 - response_body = {"error": "Input must be a list of numbers"} + response_body = { + "error": "Input must be a list of numbers" + } elif not numbers: status = 400 - response_body = {"error": "List of numbers cannot be empty"} + response_body = { + "error": "List of numbers cannot be empty" + } else: try: numbers = [float(x) for x in numbers] @@ -124,7 +132,9 @@ async def application( response_body = {"result": result} except (ValueError, TypeError): status = 422 - response_body = {"error": "All elements must be numbers"} + response_body = { + "error": "All elements must be numbers" + } except json.JSONDecodeError: status = 422 response_body = {"error": "Invalid JSON format"} @@ -137,22 +147,27 @@ async def application( 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.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, - }) + 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) From 19fe1c8a2c5aac06acf0943ca92259f0bf7c5795 Mon Sep 17 00:00:00 2001 From: devspark993 Date: Sun, 28 Sep 2025 20:05:31 +0300 Subject: [PATCH 3/6] HW 1 refactored logic for /factorial, /fibonacci, and /mean endpoint into separate functions --- hw1/app.py | 192 +++++++++++++++++++++++++++++------------------------ 1 file changed, 104 insertions(+), 88 deletions(-) diff --git a/hw1/app.py b/hw1/app.py index 8dce1adb..bbf6eef6 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -3,6 +3,107 @@ 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( scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], @@ -14,7 +115,6 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # Обработка lifespan-сообщений if scope["type"] == "lifespan": while True: @@ -49,95 +149,11 @@ async def application( response_body = {"error": "Not found"} else: if path.startswith("/factorial"): - # Обработка /factorial?n= - 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" - } - + status, response_body = await handle_factorial(query_string) elif path.startswith("/fibonacci"): - # Обработка /fibonacci/ - 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"} - + status, response_body = await handle_fibonacci(path) elif path.startswith("/mean"): - # Обработка /mean с JSON в теле запроса - 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"} + status, response_body = await handle_mean(receive) except Exception: status = 422 From 5c2a5ebe07e0bb7816b35151c5864cc6d7386fb0 Mon Sep 17 00:00:00 2001 From: devspark993 Date: Wed, 1 Oct 2025 08:46:12 +0300 Subject: [PATCH 4/6] HW 2 --- hw2/hw/shop_api/main.py | 268 +++++++++++++++++++++++++++++++++++++- hw2/hw/shop_api/models.py | 51 ++++++++ 2 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 hw2/hw/shop_api/models.py diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..f7cdab3f 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,269 @@ -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException +from fastapi.responses import JSONResponse +from typing import List, Dict, Any, Optional +from http import HTTPStatus + +from shop_api.models import ( + ItemCreate, + ItemUpdate, + ItemPatch, + ItemResponse, + CartResponse, + CartItem, +) 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 diff --git a/hw2/hw/shop_api/models.py b/hw2/hw/shop_api/models.py new file mode 100644 index 00000000..7c46180e --- /dev/null +++ b/hw2/hw/shop_api/models.py @@ -0,0 +1,51 @@ +from pydantic import BaseModel, Field, ConfigDict +from typing import List, Optional + + +# 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 # Общая цена From 785b096019d6a0930cbfbcf239295d637a2bf51d Mon Sep 17 00:00:00 2001 From: devspark993 Date: Fri, 3 Oct 2025 09:20:44 +0300 Subject: [PATCH 5/6] HW 2 + Additional Task --- hw2/hw/requirements.txt | 3 ++ hw2/hw/shop_api/client.py | 18 +++++++++ hw2/hw/shop_api/main.py | 84 ++++++++++++++++++++++++++++++++++----- hw2/hw/shop_api/models.py | 3 +- 4 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 hw2/hw/shop_api/client.py 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..eb8eda58 --- /dev/null +++ b/hw2/hw/shop_api/client.py @@ -0,0 +1,18 @@ +import sys + +from websocket import create_connection + +if len(sys.argv) < 2: + print("Usage: python client.py ") + sys.exit(1) + +chat_name = sys.argv[1] +ws = create_connection(f"ws://localhost:8000/chat/{chat_name}") + +print(f"Connected to chat room: {chat_name}") + +while True: + message = input("Enter message: ") + ws.send(message) + response = ws.recv() + print(response) # Получаем broadcast diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f7cdab3f..9a7478e6 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,16 +1,13 @@ -from fastapi import FastAPI, HTTPException -from fastapi.responses import JSONResponse -from typing import List, Dict, Any, Optional +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 ( - ItemCreate, - ItemUpdate, - ItemPatch, - ItemResponse, - CartResponse, - CartItem, -) +from shop_api.models import (CartItem, CartResponse, ItemCreate, ItemPatch, + ItemResponse, ItemUpdate) app = FastAPI(title="Shop API") @@ -267,3 +264,68 @@ async def delete_item(id: int) -> None: 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 = uuid4().hex[:8] # Случайное имя (первые 8 символов uuid) + 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) -> None: + """Broadcast сообщения в комнату""" + for ws in self.subscribers: + await ws.send_text(message) + + +# Хранение комнат для чата (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") + + try: + while True: + # Получаем сообщение от клиента + message = await ws.receive_text() + # Форматируем и broadcast + formatted_message = f"{username} :: {message}" + await broadcaster.publish(formatted_message) + except WebSocketDisconnect: + # Отписка и уведомление + username = await broadcaster.unsubscribe(ws) + await broadcaster.publish(f"{username} :: left the chat") + 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 index 7c46180e..54814529 100644 --- a/hw2/hw/shop_api/models.py +++ b/hw2/hw/shop_api/models.py @@ -1,6 +1,7 @@ -from pydantic import BaseModel, Field, ConfigDict from typing import List, Optional +from pydantic import BaseModel, ConfigDict, Field + # Pydantic-модели для валидации запросов/ответов class ItemCreate(BaseModel): From 6c76c40e4c905359f98761babbcce5ad223caab1 Mon Sep 17 00:00:00 2001 From: devspark993 Date: Sun, 5 Oct 2025 12:11:49 +0300 Subject: [PATCH 6/6] fixed bugs and errors additional task for HW 2 --- hw2/hw/shop_api/client.py | 41 ++++++++++++++++++++++++++++++++++----- hw2/hw/shop_api/main.py | 38 ++++++++++++++++++++++++++---------- 2 files changed, 64 insertions(+), 15 deletions(-) diff --git a/hw2/hw/shop_api/client.py b/hw2/hw/shop_api/client.py index eb8eda58..283b0c15 100644 --- a/hw2/hw/shop_api/client.py +++ b/hw2/hw/shop_api/client.py @@ -1,18 +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] -ws = create_connection(f"ws://localhost:8000/chat/{chat_name}") +# Подключаемся к серверу по 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: - message = input("Enter message: ") - ws.send(message) - response = ws.recv() - print(response) # Получаем broadcast + 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 9a7478e6..239fbeba 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -280,7 +280,7 @@ async def subscribe(self, ws: WebSocket) -> str: """Подписка клиента, присвоение имени""" await ws.accept() self.subscribers.append(ws) - username = uuid4().hex[:8] # Случайное имя (первые 8 символов uuid) + username = f"User_{uuid4().hex[:4]}" # случайное имя типа User_a1b2 self.usernames[ws] = username return username @@ -292,10 +292,21 @@ async def unsubscribe(self, ws: WebSocket) -> str: return username return "unknown" - async def publish(self, message: str) -> None: - """Broadcast сообщения в комнату""" + async def publish( + self, message: str, exclude_ws: Optional[WebSocket] = None + ) -> None: + """Broadcast сообщения в комнату, исключая exclude_ws""" + dead_ws = [] for ws in self.subscribers: - await ws.send_text(message) + 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, в памяти) @@ -310,22 +321,29 @@ async def ws_chat(ws: WebSocket, chat_name: str): chat_rooms[chat_name] = Broadcaster() broadcaster = chat_rooms[chat_name] - # Подписка и отправка приветствия + # Подписка и отправка приветствия (всем, кроме самого) username = await broadcaster.subscribe(ws) - await broadcaster.publish(f"{username} :: joined the chat") + await broadcaster.publish(f"{username} :: joined the chat", exclude_ws=ws) try: while True: # Получаем сообщение от клиента message = await ws.receive_text() - # Форматируем и broadcast + message = message.strip() + if not message: # Пропускаем пустые сообщения + continue + # Форматируем и broadcast (исключая отправителя) formatted_message = f"{username} :: {message}" - await broadcaster.publish(formatted_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]