diff --git a/hw1/app.py b/hw1/app.py index 6107b870..f77ba133 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,5 +1,17 @@ +from http import HTTPStatus from typing import Any, Awaitable, Callable +from routes.fibonacci import handle_fibonacci +from routes.factorial import handle_factorial +from routes.mean import handle_mean + +from helpers import send_json + + +ROUTES = { + ("GET", "/factorial"): handle_factorial, + ("GET", "/mean"): handle_mean, +} async def application( scope: dict[str, Any], @@ -12,7 +24,35 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + 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: str = scope["method"] + path: str = scope["path"] + + # Сначала проверяем точные маршруты + handler = ROUTES.get((method, path)) + if handler is not None: + await handler(scope, receive, send) + return + + # Проверяем маршруты с параметрами + if method == "GET" and path.startswith("/fibonacci/"): + await handle_fibonacci(scope, receive, send) + return + + return await send_json(send, HTTPStatus.NOT_FOUND, f"Not Found: {method} {path}") + if __name__ == "__main__": import uvicorn diff --git a/hw1/helpers.py b/hw1/helpers.py new file mode 100644 index 00000000..d539fcb5 --- /dev/null +++ b/hw1/helpers.py @@ -0,0 +1,51 @@ +import json +from http import HTTPStatus +from typing import Any, Awaitable, Callable + +async def read_body(receive: Callable[[], Awaitable[dict[str, Any]]]) -> bytes: + """ + Читает тело HTTP-запроса целиком, собирая все чанки http.request + до тех пор, пока more_body == False. + """ + body = bytearray() + while True: + event = await receive() + assert event["type"] == "http.request" + body.extend(event.get("body", b"")) + if not event.get("more_body", False): + break + + return bytes(body) + + +async def send_json( + send: Callable[[dict[str, Any]], Awaitable[None]], + status: HTTPStatus | int, + response: dict[str, Any] | str, + headers: list[tuple[bytes, bytes]] | None = None, +) -> None: + hdrs: list[tuple[bytes, bytes]] = [ + (b"content-type", b"application/json"), + ] + if headers: + hdrs.extend(headers) + + if isinstance(response, dict): + body_content = json.dumps(response).encode("utf-8") + else: + body_content = str(response).encode("utf-8") + + await send( + { + "type": "http.response.start", + "status": int(status), + "headers": hdrs, + } + ) + await send( + { + "type": "http.response.body", + "body": body_content, + "more_body": False, + } + ) diff --git a/hw1/routes/factorial.py b/hw1/routes/factorial.py new file mode 100644 index 00000000..9f618b86 --- /dev/null +++ b/hw1/routes/factorial.py @@ -0,0 +1,37 @@ +from http import HTTPStatus +from typing import Any, Awaitable, Callable +from urllib.parse import parse_qs + +from helpers import send_json + + +async def handle_factorial( + scope: dict[str, Any], + receive: Callable[[], Awaitable[dict[str, Any]]], + send: Callable[[dict[str, Any]], Awaitable[None]], +): + query_string = scope.get("query_string", b"").decode("utf-8") + query_params = parse_qs(query_string) + + if "n" not in query_params: + return await send_json(send, HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "Missing parameter 'n'"}) + + try: + n = int(query_params["n"][0]) + except (ValueError, IndexError): + return await send_json(send, HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "Parameter 'n' must be an integer"}) + + if n < 0: + return await send_json(send, HTTPStatus.BAD_REQUEST, {"error": "Parameter 'n' must be non-negative"}) + + result = calculate_factorial(n) + return await send_json(send, HTTPStatus.OK, {"result": result}) + +def calculate_factorial(n: int) -> int: + if n == 0: + return 1 + + res = 1 + for i in range(1, n + 1): + res *= i + return res diff --git a/hw1/routes/fibonacci.py b/hw1/routes/fibonacci.py new file mode 100644 index 00000000..3cd18a87 --- /dev/null +++ b/hw1/routes/fibonacci.py @@ -0,0 +1,37 @@ +from http import HTTPStatus +from typing import Any, Awaitable, Callable + +from helpers import send_json + + +async def handle_fibonacci( + scope: dict[str, Any], + receive: Callable[[], Awaitable[dict[str, Any]]], + send: Callable[[dict[str, Any]], Awaitable[None]], +): + path = scope.get("path", "") + + path_parts = path.split("/") + if len(path_parts) != 3: + return await send_json(send, HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "Invalid path format"}) + + try: + n = int(path_parts[2]) + except ValueError: + return await send_json(send, HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "Parameter must be an integer"}) + + if n < 0: + return await send_json(send, HTTPStatus.BAD_REQUEST, {"error": "Parameter must be non-negative"}) + + result = calculate_fibonacci(n) + return await send_json(send, HTTPStatus.OK, {"result": result}) + + +def calculate_fibonacci(n: int) -> int: + if n <= 1: + return n + + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + return b diff --git a/hw1/routes/mean.py b/hw1/routes/mean.py new file mode 100644 index 00000000..41b9b665 --- /dev/null +++ b/hw1/routes/mean.py @@ -0,0 +1,38 @@ +import json +from http import HTTPStatus +from typing import Any, Awaitable, Callable + +from helpers import read_body, send_json + + +async def handle_mean( + scope: dict[str, Any], + receive: Callable[[], Awaitable[dict[str, Any]]], + send: Callable[[dict[str, Any]], Awaitable[None]], +): + body = await read_body(receive) + + if not body: + return await send_json(send, HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "Request body is required"}) + + try: + data = json.loads(body) + except json.JSONDecodeError: + return await send_json(send, HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "Invalid JSON"}) + + if not isinstance(data, list): + return await send_json(send, HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "Data must be a list"}) + + if len(data) == 0: + return await send_json(send, HTTPStatus.BAD_REQUEST, {"error": "List cannot be empty"}) + + for item in data: + if not isinstance(item, (int, float)): + return await send_json(send, HTTPStatus.UNPROCESSABLE_ENTITY, {"error": "All elements must be numbers"}) + + result = calculate_mean(data) + return await send_json(send, HTTPStatus.OK, {"result": result}) + + +def calculate_mean(numbers: list[int | float]) -> float: + return sum(numbers) / len(numbers) diff --git a/hw2/grpc_example/ping_pb2.py b/hw2/grpc_example/ping_pb2.py new file mode 100644 index 00000000..29effdcb --- /dev/null +++ b/hw2/grpc_example/ping_pb2.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# NO CHECKED-IN PROTOBUF GENCODE +# source: ping.proto +# Protobuf Python Version: 6.31.1 +"""Generated protocol buffer code.""" +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import runtime_version as _runtime_version +from google.protobuf import symbol_database as _symbol_database +from google.protobuf.internal import builder as _builder +_runtime_version.ValidateProtobufRuntimeVersion( + _runtime_version.Domain.PUBLIC, + 6, + 31, + 1, + '', + 'ping.proto' +) +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\nping.proto\x12\x07\x65xample\"\x1e\n\x0bPingRequest\x12\x0f\n\x07message\x18\x01 \x01(\t\"\x1f\n\x0cPongResponse\x12\x0f\n\x07message\x18\x01 \x01(\t2}\n\x07\x45xample\x12\x33\n\x04Ping\x12\x14.example.PingRequest\x1a\x15.example.PongResponse\x12=\n\nPingStream\x12\x14.example.PingRequest\x1a\x15.example.PongResponse(\x01\x30\x01\x62\x06proto3') + +_globals = globals() +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'ping_pb2', _globals) +if not _descriptor._USE_C_DESCRIPTORS: + DESCRIPTOR._loaded_options = None + _globals['_PINGREQUEST']._serialized_start=23 + _globals['_PINGREQUEST']._serialized_end=53 + _globals['_PONGRESPONSE']._serialized_start=55 + _globals['_PONGRESPONSE']._serialized_end=86 + _globals['_EXAMPLE']._serialized_start=88 + _globals['_EXAMPLE']._serialized_end=213 +# @@protoc_insertion_point(module_scope) diff --git a/hw2/grpc_example/ping_pb2.pyi b/hw2/grpc_example/ping_pb2.pyi new file mode 100644 index 00000000..4d38c1bd --- /dev/null +++ b/hw2/grpc_example/ping_pb2.pyi @@ -0,0 +1,17 @@ +from google.protobuf import descriptor as _descriptor +from google.protobuf import message as _message +from typing import ClassVar as _ClassVar, Optional as _Optional + +DESCRIPTOR: _descriptor.FileDescriptor + +class PingRequest(_message.Message): + __slots__ = ("message",) + MESSAGE_FIELD_NUMBER: _ClassVar[int] + message: str + def __init__(self, message: _Optional[str] = ...) -> None: ... + +class PongResponse(_message.Message): + __slots__ = ("message",) + MESSAGE_FIELD_NUMBER: _ClassVar[int] + message: str + def __init__(self, message: _Optional[str] = ...) -> None: ... diff --git a/hw2/grpc_example/ping_pb2_grpc.py b/hw2/grpc_example/ping_pb2_grpc.py new file mode 100644 index 00000000..ee077651 --- /dev/null +++ b/hw2/grpc_example/ping_pb2_grpc.py @@ -0,0 +1,140 @@ +# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! +"""Client and server classes corresponding to protobuf-defined services.""" +import grpc +import warnings + +import hw2.grpc_example.ping_pb2 as ping__pb2 + +GRPC_GENERATED_VERSION = '1.75.1' +GRPC_VERSION = grpc.__version__ +_version_not_supported = False + +try: + from grpc._utilities import first_version_is_lower + _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) +except ImportError: + _version_not_supported = True + +if _version_not_supported: + raise RuntimeError( + f'The grpc package installed is at version {GRPC_VERSION},' + + f' but the generated code in ping_pb2_grpc.py depends on' + + f' grpcio>={GRPC_GENERATED_VERSION}.' + + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' + ) + + +class ExampleStub(object): + """Missing associated documentation comment in .proto file.""" + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Ping = channel.unary_unary( + '/example.Example/Ping', + request_serializer=ping__pb2.PingRequest.SerializeToString, + response_deserializer=ping__pb2.PongResponse.FromString, + _registered_method=True) + self.PingStream = channel.stream_stream( + '/example.Example/PingStream', + request_serializer=ping__pb2.PingRequest.SerializeToString, + response_deserializer=ping__pb2.PongResponse.FromString, + _registered_method=True) + + +class ExampleServicer(object): + """Missing associated documentation comment in .proto file.""" + + def Ping(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + def PingStream(self, request_iterator, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_ExampleServicer_to_server(servicer, server): + rpc_method_handlers = { + 'Ping': grpc.unary_unary_rpc_method_handler( + servicer.Ping, + request_deserializer=ping__pb2.PingRequest.FromString, + response_serializer=ping__pb2.PongResponse.SerializeToString, + ), + 'PingStream': grpc.stream_stream_rpc_method_handler( + servicer.PingStream, + request_deserializer=ping__pb2.PingRequest.FromString, + response_serializer=ping__pb2.PongResponse.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'example.Example', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('example.Example', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class Example(object): + """Missing associated documentation comment in .proto file.""" + + @staticmethod + def Ping(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/example.Example/Ping', + ping__pb2.PingRequest.SerializeToString, + ping__pb2.PongResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + + @staticmethod + def PingStream(request_iterator, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.stream_stream( + request_iterator, + target, + '/example.Example/PingStream', + ping__pb2.PingRequest.SerializeToString, + ping__pb2.PongResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/hw2/hw/WEBSOCKET_CHAT.md b/hw2/hw/WEBSOCKET_CHAT.md new file mode 100644 index 00000000..8935622c --- /dev/null +++ b/hw2/hw/WEBSOCKET_CHAT.md @@ -0,0 +1,91 @@ +# WebSocket Chat - Дополнительное задание + +## Описание + +Реализован WebSocket чат с поддержкой отдельных комнат. Каждый пользователь получает случайное имя при подключении, и все сообщения форматируются как `{username} :: {message}`. + +## Особенности + +- **Отдельные комнаты**: Пользователи с одинаковым `chat_name` подключаются к одной комнате +- **Случайные имена**: Каждому клиенту автоматически присваивается уникальное имя (например, `BraveTiger1a2b`) +- **Broadcast**: Сообщения отправляются всем участникам комнаты +- **Уведомления**: О подключении и отключении пользователей + +## Запуск сервера + +```bash +cd hw2/hw +export PYTHONPATH=${PWD} +uvicorn shop_api.main:app --reload +``` +будет на `http://localhost:8000` + +## Тестирование + +### Использование игршечного клиента + +1. Запустить клиент в первом и во втором терминале: +```bash +cd hw2/hw +python test_chat_client.py general +``` + +Теперь можно обмениваться сообщениями между клиентами + +## Примеры использования + +### Пример 1: Два пользователя в одной комнате + +**Терминал 1:** +``` +$ python test_chat_client.py room1 +✓ Подключено к чату 'room1' +> BraveTiger1a2b :: joined the chat +> MightyEagle5f3c :: joined the chat +> Hello everyone! +> BraveTiger1a2b :: Hello everyone! +> MightyEagle5f3c :: Hi BraveTiger! +``` + +**Терминал 2:** +``` +$ python test_chat_client.py room1 +✓ Подключено к чату 'room1' +> MightyEagle5f3c :: joined the chat +> BraveTiger1a2b :: Hello everyone! +> Hi BraveTiger! +> MightyEagle5f3c :: Hi BraveTiger! +``` + +### Пример 2: Разные комнаты + +Пользователи в `room1` не будут видеть сообщения от пользователей в `room2` и наоборот. + +## API Endpoint + +``` +WebSocket: /chat/{chat_name} +``` + +**Параметры:** +- `chat_name` (string): Название комнаты чата + +**Формат сообщений:** +- Клиент отправляет: `"text message"` +- Сервер отправляет: `"{username} :: text message"` + +## Архитектура + +- `chat_manager.py` - Управление комнатами и пользователями + - `ChatRoom` - Класс комнаты с пользователями + - `ChatManager` - Глобальный менеджер всех комнат +- `chat_routes.py` - WebSocket endpoint для чата +- `test_chat_client.py` - Простой клиент для тестирования + +## Технические детали + +- Автоматическая очистка пустых комнат при отключении последнего пользователя +- Обработка внезапных отключений +- Генерация уникальных имен пользователей из прилагательных и животных +- Поддержка неограниченного количества одновременных комнат и пользователей + diff --git a/hw2/hw/shop_api/cart_routes.py b/hw2/hw/shop_api/cart_routes.py new file mode 100644 index 00000000..a9a73352 --- /dev/null +++ b/hw2/hw/shop_api/cart_routes.py @@ -0,0 +1,66 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + +from . import store +from .contracts import CartIdResponse, CartResponse + +router = APIRouter(prefix="/cart", tags=["cart"]) + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def create_cart(response: Response) -> CartIdResponse: + cart = store.create_cart() + + response.headers["location"] = f"/cart/{cart.id}" + + return CartIdResponse(id=cart.id) + + +@router.get("/{id}") +async def get_cart_by_id(id: int) -> CartResponse: + cart = store.get_cart(id) + + if not cart: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Cart with id={id} not found", + ) + + return CartResponse.from_entity(cart) + + +@router.get("/") +async def get_carts( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat | None, Query()] = None, + max_price: Annotated[NonNegativeFloat | None, Query()] = None, + min_quantity: Annotated[NonNegativeInt | None, Query()] = None, + max_quantity: Annotated[NonNegativeInt | None, Query()] = None, +) -> list[CartResponse]: + carts = store.get_carts( + offset=offset, + limit=limit, + min_price=min_price, + max_price=max_price, + min_quantity=min_quantity, + max_quantity=max_quantity, + ) + + return [CartResponse.from_entity(cart) for cart in carts] + + +@router.post("/{cart_id}/add/{item_id}") +async def add_item_to_cart(cart_id: int, item_id: int) -> CartResponse: + cart = store.add_item_to_cart(cart_id, item_id) + + if not cart: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Cart with id={cart_id} or item with id={item_id} not found", + ) + + return CartResponse.from_entity(cart) diff --git a/hw2/hw/shop_api/chat_manager.py b/hw2/hw/shop_api/chat_manager.py new file mode 100644 index 00000000..8f3d2327 --- /dev/null +++ b/hw2/hw/shop_api/chat_manager.py @@ -0,0 +1,73 @@ +from dataclasses import dataclass, field +from typing import Dict +from uuid import uuid4 + +from fastapi import WebSocket + + +@dataclass(slots=True) +class ChatRoom: + name: str + connections: Dict[str, WebSocket] = field(default_factory=dict) + + async def add_user(self, ws: WebSocket) -> str: + await ws.accept() + username = self._generate_random_username() + self.connections[username] = ws + return username + + def remove_user(self, username: str) -> None: + if username in self.connections: + del self.connections[username] + + async def broadcast(self, message: str, sender_username: str) -> None: + formatted_message = f"{sender_username} :: {message}" + + disconnected_users = [] + for username, ws in self.connections.items(): + try: + await ws.send_text(formatted_message) + except Exception: + disconnected_users.append(username) + + for username in disconnected_users: + self.remove_user(username) + + def _generate_random_username(self) -> str: + adj = [ + "Happy", "Brave", "Swift", "Clever", "Mighty", + "Silent", "Golden", "Mystic", "Noble", "Wild" + ] + names = [ + "Tiger", "Eagle", "Dragon", "Phoenix", "Wolf", + "Bear", "Fox", "Lion", "Hawk", "Panther" + ] + + import random + adjective = random.choice(adj) + name = random.choice(names) + unique_id = str(uuid4())[:4] + + return f"{adjective}{name}{unique_id}" + + +@dataclass(slots=True) +class ChatManager: + rooms: Dict[str, ChatRoom] = field(default_factory=dict) + + def get_or_create_room(self, room_name: str) -> ChatRoom: + if room_name not in self.rooms: + self.rooms[room_name] = ChatRoom(name=room_name) + return self.rooms[room_name] + + def cleanup_empty_rooms(self) -> None: + empty_rooms = [ + name for name, room in self.rooms.items() + if len(room.connections) == 0 + ] + for room_name in empty_rooms: + del self.rooms[room_name] + + +chat_manager = ChatManager() + diff --git a/hw2/hw/shop_api/chat_routes.py b/hw2/hw/shop_api/chat_routes.py new file mode 100644 index 00000000..e81de527 --- /dev/null +++ b/hw2/hw/shop_api/chat_routes.py @@ -0,0 +1,26 @@ +from fastapi import APIRouter, WebSocket, WebSocketDisconnect + +from .chat_manager import chat_manager + +router = APIRouter(tags=["chat"]) + + +@router.websocket("/chat/{chat_name}") +async def websocket_chat(websocket: WebSocket, chat_name: str): + room = chat_manager.get_or_create_room(chat_name) + username = await room.add_user(websocket) + + await room.broadcast(f"joined the chat", username) + + try: + while True: + message = await websocket.receive_text() + + await room.broadcast(message, username) + + except WebSocketDisconnect: + room.remove_user(username) + await room.broadcast(f"left the chat", username) + + chat_manager.cleanup_empty_rooms() + diff --git a/hw2/hw/shop_api/contracts.py b/hw2/hw/shop_api/contracts.py new file mode 100644 index 00000000..d62764b9 --- /dev/null +++ b/hw2/hw/shop_api/contracts.py @@ -0,0 +1,67 @@ +from pydantic import BaseModel, ConfigDict + +from .models import CartEntity, CartItem, ItemEntity + + + +class ItemRequest(BaseModel): + name: str + price: float + + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool = False + + @staticmethod + def from_entity(entity: ItemEntity) -> "ItemResponse": + return ItemResponse( + id=entity.id, + name=entity.name, + price=entity.price, + deleted=entity.deleted, + ) + + +class PatchItemRequest(BaseModel): + name: str | None = None + price: float | None = None + + model_config = ConfigDict(extra="forbid") + + + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + + @staticmethod + def from_cart_item(item: CartItem) -> "CartItemResponse": + return CartItemResponse( + id=item.id, + name=item.name, + quantity=item.quantity, + available=item.available, + ) + + +class CartResponse(BaseModel): + id: int + items: list[CartItemResponse] + price: float + + @staticmethod + def from_entity(entity: CartEntity) -> "CartResponse": + return CartResponse( + id=entity.id, + items=[CartItemResponse.from_cart_item(item) for item in entity.items], + price=entity.price, + ) + + +class CartIdResponse(BaseModel): + id: int diff --git a/hw2/hw/shop_api/item_routes.py b/hw2/hw/shop_api/item_routes.py new file mode 100644 index 00000000..474a7ca2 --- /dev/null +++ b/hw2/hw/shop_api/item_routes.py @@ -0,0 +1,99 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + +from . import store +from .contracts import ItemRequest, ItemResponse, PatchItemRequest +from .models import ItemInfo, PatchItemInfo + +router = APIRouter(prefix="/item", tags=["item"]) + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def create_item(item_request: ItemRequest, response: Response) -> ItemResponse: + item_info = ItemInfo( + name=item_request.name, + price=item_request.price, + deleted=False, + ) + item = store.add_item(item_info) + + response.headers["location"] = f"/item/{item.id}" + + return ItemResponse.from_entity(item) + + +@router.get("/{id}") +async def get_item_by_id(id: int) -> ItemResponse: + item = store.get_item(id) + + if not item: + raise HTTPException( + status_code=HTTPStatus.NOT_FOUND, + detail=f"Item with id={id} not found", + ) + + return ItemResponse.from_entity(item) + + +@router.get("/") +async def get_items( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat | None, Query()] = None, + max_price: Annotated[NonNegativeFloat | None, Query()] = None, + show_deleted: bool = False, +) -> list[ItemResponse]: + items = store.get_items( + offset=offset, + limit=limit, + min_price=min_price, + max_price=max_price, + show_deleted=show_deleted, + ) + + return [ItemResponse.from_entity(item) for item in items] + + +@router.put("/{id}") +async def update_item(id: int, item_request: ItemRequest) -> ItemResponse: + item_info = ItemInfo( + name=item_request.name, + price=item_request.price, + ) + + item = store.update_item(id, item_info) + + if not item: + raise HTTPException( + status_code=HTTPStatus.NOT_MODIFIED, + detail=f"Item with id={id} not found", + ) + + return ItemResponse.from_entity(item) + + +@router.patch("/{id}") +async def patch_item(id: int, patch_request: PatchItemRequest) -> ItemResponse: + patch_info = PatchItemInfo( + name=patch_request.name, + price=patch_request.price, + ) + + item = store.patch_item(id, patch_info) + + if not item: + raise HTTPException( + status_code=HTTPStatus.NOT_MODIFIED, + detail=f"Item with id={id} not found or deleted", + ) + + return ItemResponse.from_entity(item) + + +@router.delete("/{id}") +async def delete_item(id: int) -> Response: + store.delete_item(id) + return Response(status_code=HTTPStatus.OK) diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..89976e15 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,9 @@ from fastapi import FastAPI +from . import cart_routes, chat_routes, item_routes + app = FastAPI(title="Shop API") + +app.include_router(item_routes.router) +app.include_router(cart_routes.router) +app.include_router(chat_routes.router) diff --git a/hw2/hw/shop_api/models.py b/hw2/hw/shop_api/models.py new file mode 100644 index 00000000..8cd9b2f5 --- /dev/null +++ b/hw2/hw/shop_api/models.py @@ -0,0 +1,37 @@ +from dataclasses import dataclass, field + + +@dataclass(slots=True) +class ItemInfo: + name: str + price: float + deleted: bool = False + + +@dataclass(slots=True) +class ItemEntity: + id: int + name: str + price: float + deleted: bool = False + + +@dataclass(slots=True) +class PatchItemInfo: + name: str | None = None + price: float | None = None + + +@dataclass(slots=True) +class CartItem: + id: int + name: str + quantity: int + available: bool + + +@dataclass(slots=True) +class CartEntity: + id: int + items: list[CartItem] = field(default_factory=list) + price: float = 0.0 diff --git a/hw2/hw/shop_api/store.py b/hw2/hw/shop_api/store.py new file mode 100644 index 00000000..d564db19 --- /dev/null +++ b/hw2/hw/shop_api/store.py @@ -0,0 +1,175 @@ +from typing import Iterable + +from .models import ( + CartEntity, + CartItem, + ItemEntity, + ItemInfo, + PatchItemInfo, +) + + +# Хранилище данных в памяти +_items: dict[int, ItemEntity] = {} +_carts: dict[int, CartEntity] = {} + + +def _int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_item_id_generator = _int_id_generator() +_cart_id_generator = _int_id_generator() + + + +def add_item(info: ItemInfo) -> ItemEntity: + item_id = next(_item_id_generator) + item = ItemEntity( + id=item_id, + name=info.name, + price=info.price, + deleted=info.deleted, + ) + _items[item_id] = item + return item + + +def get_item(item_id: int) -> ItemEntity | None: + item = _items.get(item_id) + if item and item.deleted: + return None + return item + + +def get_item_including_deleted(item_id: int) -> ItemEntity | None: + return _items.get(item_id) + + +def get_items( + offset: int = 0, + limit: int = 10, + min_price: float | None = None, + max_price: float | None = None, + show_deleted: bool = False, +) -> list[ItemEntity]: + result = [] + + for item in _items.values(): + 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] + + +def update_item(item_id: int, info: ItemInfo) -> ItemEntity | None: + if item_id not in _items: + return None + + item = ItemEntity( + id=item_id, + name=info.name, + price=info.price, + deleted=_items[item_id].deleted, + ) + _items[item_id] = item + return item + + +def patch_item(item_id: int, patch_info: PatchItemInfo) -> ItemEntity | None: + item = _items.get(item_id) + if not item or item.deleted: + return None + + if patch_info.name is not None: + item.name = patch_info.name + if patch_info.price is not None: + item.price = patch_info.price + + return item + + +def delete_item(item_id: int) -> bool: + if item_id in _items: + _items[item_id].deleted = True + return True + return False + + +def create_cart() -> CartEntity: + cart_id = next(_cart_id_generator) + cart = CartEntity(id=cart_id, items=[], price=0.0) + _carts[cart_id] = cart + return cart + + +def get_cart(cart_id: int) -> CartEntity | None: + return _carts.get(cart_id) + + +def get_carts( + 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, +) -> list[CartEntity]: + result = [] + + for cart in _carts.values(): + if min_price is not None and cart.price < min_price: + continue + if max_price is not None and cart.price > max_price: + continue + + total_quantity = sum(item.quantity for item in cart.items) + 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) + + return result[offset:offset + limit] + + +def add_item_to_cart(cart_id: int, item_id: int) -> CartEntity | None: + cart = _carts.get(cart_id) + if not cart: + return None + + item = get_item_including_deleted(item_id) + if not item: + return None + + for cart_item in cart.items: + if cart_item.id == item_id: + cart_item.quantity += 1 + break + else: + cart_item = CartItem( + id=item.id, + name=item.name, + quantity=1, + available=not item.deleted, + ) + cart.items.append(cart_item) + + cart.price = sum( + get_item_including_deleted(ci.id).price * ci.quantity + for ci in cart.items + if get_item_including_deleted(ci.id) + ) + + return cart diff --git a/hw2/hw/test_chat_client.py b/hw2/hw/test_chat_client.py new file mode 100644 index 00000000..d34aac4e --- /dev/null +++ b/hw2/hw/test_chat_client.py @@ -0,0 +1,63 @@ +import asyncio +import sys + +import websockets + + +async def chat_client(chat_name: str): + uri = f"ws://localhost:8000/chat/{chat_name}" + + print(f"Подключение к чату '{chat_name}'...") + + try: + async with websockets.connect(uri) as websocket: + print(f"Подключено к чату '{chat_name}'") + print("Введите сообщение (или 'exit' для выхода):\n") + + async def receive_messages(): + try: + async for message in websocket: + print(f"\r{message}") + print("> ", end="", flush=True) + except websockets.exceptions.ConnectionClosed: + print("\n[Соединение закрыто]") + + async def send_messages(): + try: + while True: + message = await asyncio.get_event_loop().run_in_executor( + None, lambda: input("> ") + ) + + if message.lower() == "exit": + break + + if message.strip(): + await websocket.send(message) + except (KeyboardInterrupt, EOFError): + pass + + await asyncio.gather( + receive_messages(), + send_messages(), + ) + + except Exception as e: + print(f"Ошибка: {e}") + print("\nУбедитесь, что сервер запущен:") + print(" cd hw2/hw && uvicorn shop_api.main:app --reload") + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Использование: python test_chat_client.py ") + print("Пример: python test_chat_client.py general") + sys.exit(1) + + chat_name = sys.argv[1] + + try: + asyncio.run(chat_client(chat_name)) + except KeyboardInterrupt: + print("\n[Выход]") + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..e50ba5bc --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "python-backend-hw" +version = "0.1.0" +description = "Add your description here" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "uvicorn>=0.37.0", +] diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..d9f89cb1 --- /dev/null +++ b/uv.lock @@ -0,0 +1,57 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "click" +version = "8.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "python-backend-hw" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "uvicorn" }, +] + +[package.metadata] +requires-dist = [{ name = "uvicorn", specifier = ">=0.37.0" }] + +[[package]] +name = "uvicorn" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/57/1616c8274c3442d802621abf5deb230771c7a0fec9414cb6763900eb3868/uvicorn-0.37.0.tar.gz", hash = "sha256:4115c8add6d3fd536c8ee77f0e14a7fd2ebba939fed9b02583a97f80648f9e13", size = 80367, upload-time = "2025-09-23T13:33:47.486Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/cd/584a2ceb5532af99dd09e50919e3615ba99aa127e9850eafe5f31ddfdb9a/uvicorn-0.37.0-py3-none-any.whl", hash = "sha256:913b2b88672343739927ce381ff9e2ad62541f9f8289664fa1d1d3803fa2ce6c", size = 67976, upload-time = "2025-09-23T13:33:45.842Z" }, +]