From 36142d05482af1664af43b809dd3baa60dfcbb76 Mon Sep 17 00:00:00 2001 From: sidoine Date: Sat, 20 Sep 2025 20:02:14 +0300 Subject: [PATCH 01/48] Implement ASGI application with fibonacci, factorial, and mean endpoints --- hw1/app.py | 193 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 191 insertions(+), 2 deletions(-) diff --git a/hw1/app.py b/hw1/app.py index 6107b870..3b2ff143 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,8 @@ +import math +import json +from http import HTTPStatus from typing import Any, Awaitable, Callable +from urllib.parse import parse_qs async def application( @@ -12,8 +16,193 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if scope["type"] != "http": + await send_error(send, HTTPStatus.BAD_REQUEST, "Only HTTP supported") + return + + path = scope["path"] + method = scope["method"] + + # Обработка различных маршрутов + if method == "GET" and path == "/factorial": + await handle_factorial(scope, receive, send) + elif method == "GET" and path.startswith("/fibonacci/"): + await handle_fibonacci(scope, receive, send) + elif method in ["GET", "POST"] and path == "/mean": + await handle_mean(scope, receive, send) # Supporte GET et POST + else: + await send_error(send, HTTPStatus.NOT_FOUND, "Endpoint not found") + + +async def handle_factorial(scope, receive, send): + """Обработка GET /factorial?n=5""" + query_params = parse_query_string(scope["query_string"]) + n_str = query_params.get("n", [""])[0] + + # Cas limite: paramètre manquant + if not n_str: + await send_error(send, HTTPStatus.UNPROCESSABLE_ENTITY, "Missing parameter 'n'") + return + + try: + n = int(n_str) + except ValueError: + await send_error(send, HTTPStatus.UNPROCESSABLE_ENTITY, "Parameter 'n' must be an integer") + return + + # Cas limite: nombre négatif + if n < 0: + await send_error(send, HTTPStatus.BAD_REQUEST, "Invalid value for n, must be non-negative") + return + + # Cas limite: nombre trop grand + if n > 1000: + await send_error(send, HTTPStatus.BAD_REQUEST, "Value too large for n") + return + + result = math.factorial(n) + await send_json_response(send, {"result": result}) + + +async def handle_fibonacci(scope, receive, send): + """Обработка GET /fibonacci/5""" + path_parts = scope["path"].split("/") + + # Cas limite: format d'URL incorrect + if len(path_parts) < 3: + await send_error(send, HTTPStatus.UNPROCESSABLE_ENTITY, "Invalid URL format") + return + + try: + n = int(path_parts[2]) + except ValueError: + await send_error(send, HTTPStatus.UNPROCESSABLE_ENTITY, "Path parameter must be an integer") + return + + # Cas limite: nombre négatif + if n < 0: + await send_error(send, HTTPStatus.BAD_REQUEST, "Invalid value for n, must be non-negative") + return + + # Cas limite: nombre trop grand + if n > 1000: + await send_error(send, HTTPStatus.BAD_REQUEST, "Value too large for n") + return + + # Расчет Fibonacci + 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 + + await send_json_response(send, {"result": result}) + + +async def handle_mean(scope, receive, send): + """Обработка /mean - supporte GET et POST avec différents formats""" + # Essayer de lire le JSON depuis le body (pour les tests GET avec JSON) + body = await read_request_body(receive) + + has_json_body = False + numbers = [] + + if body.strip(): + try: + data = json.loads(body) + if isinstance(data, list): + numbers = [float(x) for x in data] + has_json_body = True + except (ValueError, TypeError, json.JSONDecodeError): + pass # Ignorer et essayer avec query parameter + + # Si pas de JSON body valide, essayer avec query parameter + if not has_json_body: + query_params = parse_query_string(scope["query_string"]) + numbers_str = query_params.get("numbers", [""])[0] + + if not numbers_str: + await send_error(send, HTTPStatus.UNPROCESSABLE_ENTITY, "Missing parameter 'numbers' or JSON body") + return + + try: + numbers = [float(x.strip()) for x in numbers_str.split(",") if x.strip()] + except ValueError: + await send_error(send, HTTPStatus.UNPROCESSABLE_ENTITY, "Parameter 'numbers' must be comma-separated floats") + return + + # Validation commune + if len(numbers) == 0: + await send_error(send, HTTPStatus.BAD_REQUEST, "Numbers array must not be empty") + return + + # Vérifier les valeurs non numériques + if any(math.isnan(x) or math.isinf(x) for x in numbers): + await send_error(send, HTTPStatus.BAD_REQUEST, "Numbers must be finite values") + return + + # Calcul du résultat + result = sum(numbers) / len(numbers) + await send_json_response(send, {"result": result}) + + +async def read_request_body(receive) -> 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() + + +async def send_json_response(send, data: dict): + """Отправка JSON ответа""" + response_body = json.dumps(data).encode() + + await send({ + "type": "http.response.start", + "status": HTTPStatus.OK.value, + "headers": [ + [b"content-type", b"application/json"], + ], + }) + + await send({ + "type": "http.response.body", + "body": response_body, + }) + + +async def send_error(send, status: HTTPStatus, message: str): + """Отправка HTTP ошибки""" + error_data = {"error": message} + response_body = json.dumps(error_data).encode() + + await send({ + "type": "http.response.start", + "status": status.value, + "headers": [ + [b"content-type", b"application/json"], + ], + }) + + await send({ + "type": "http.response.body", + "body": response_body, + }) + + +def parse_query_string(query_string: bytes) -> dict: + """Парсинг query string параметров""" + return parse_qs(query_string.decode()) + 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 From 4faea1ff3f4b0aabc71b9d3356a63542a7b234e8 Mon Sep 17 00:00:00 2001 From: sidoine Date: Sat, 4 Oct 2025 20:30:37 +0300 Subject: [PATCH 02/48] hw2 shop api --- hw2/hw/python | 0 hw2/hw/shop_api/cart/contracts.py | 38 +++++++++ hw2/hw/shop_api/cart/routers.py | 72 ++++++++++++++++ hw2/hw/shop_api/cart/store/__init__.py | 13 +++ hw2/hw/shop_api/cart/store/models.py | 21 +++++ hw2/hw/shop_api/cart/store/queries.py | 89 ++++++++++++++++++++ hw2/hw/shop_api/item/contracts.py | 40 +++++++++ hw2/hw/shop_api/item/routers.py | 112 +++++++++++++++++++++++++ hw2/hw/shop_api/item/store/__init__.py | 14 ++++ hw2/hw/shop_api/item/store/models.py | 20 +++++ hw2/hw/shop_api/item/store/queries.py | 80 ++++++++++++++++++ hw2/hw/shop_api/main.py | 11 +++ hw2/hw/shop_api/uvicorn | 0 13 files changed, 510 insertions(+) create mode 100644 hw2/hw/python create mode 100644 hw2/hw/shop_api/cart/contracts.py create mode 100644 hw2/hw/shop_api/cart/routers.py create mode 100644 hw2/hw/shop_api/cart/store/__init__.py create mode 100644 hw2/hw/shop_api/cart/store/models.py create mode 100644 hw2/hw/shop_api/cart/store/queries.py create mode 100644 hw2/hw/shop_api/item/contracts.py create mode 100644 hw2/hw/shop_api/item/routers.py create mode 100644 hw2/hw/shop_api/item/store/__init__.py create mode 100644 hw2/hw/shop_api/item/store/models.py create mode 100644 hw2/hw/shop_api/item/store/queries.py create mode 100644 hw2/hw/shop_api/uvicorn diff --git a/hw2/hw/python b/hw2/hw/python new file mode 100644 index 00000000..e69de29b diff --git a/hw2/hw/shop_api/cart/contracts.py b/hw2/hw/shop_api/cart/contracts.py new file mode 100644 index 00000000..ae1c2509 --- /dev/null +++ b/hw2/hw/shop_api/cart/contracts.py @@ -0,0 +1,38 @@ +from __future__ import annotations +from typing import Any + +from pydantic import BaseModel + +from shop_api.cart.store.models import CartEntity, CartInfo, CartItemInfo + + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + + +# class CartItemRequest(BaseModel): +# items: list[CartItemInfo] +# price: float + +# def as_cart_info(self) -> CartInfo: +# items = [CartItemInfo(**item.dict()) for item in self.items] +# # price = +# return CartInfo(items=items, price=self.price) + + + +class CartResponse(BaseModel): + id: int + items: list[CartItemInfo] + price: float + + @staticmethod + def from_entity(entity: CartEntity) -> CartResponse: + return CartResponse( + id=entity.id, + items=entity.info.items, + price=entity.info.price, + ) \ No newline at end of file diff --git a/hw2/hw/shop_api/cart/routers.py b/hw2/hw/shop_api/cart/routers.py new file mode 100644 index 00000000..d510513c --- /dev/null +++ b/hw2/hw/shop_api/cart/routers.py @@ -0,0 +1,72 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + + +from shop_api.cart import store +from shop_api.cart.contracts import CartResponse + +import shop_api +import shop_api.item +import shop_api.item.store + + +router = APIRouter(prefix="/cart") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_cart(response: Response) -> CartResponse: + entity = store.create() + + response.headers["location"] = f"/cart/{entity.id}" + return CartResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested cart", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested cart as one was not found", + }, + }, +) +async def get_cart_by_id(id: int): + entity = store.get_one(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /cart/{id} was not found", + ) + + return CartResponse.from_entity(entity) + + +@router.get("/") +async def get_cart_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + min_quantity: Annotated[NonNegativeFloat, Query()] | None = None, + max_quantity: Annotated[NonNegativeFloat, Query()] | None = None, +): + return [ + CartResponse.from_entity(e) + for e in store.get_many( + offset, limit, min_price, max_price, min_quantity, max_quantity + ) + ] + +@router.post("/{cart_id}/add/{item_id}") +async def add_to_cart(cart_id: int, item_id: int): + item_entity = shop_api.item.store.get_one(item_id) + + entity = store.add(cart_id, item_entity) + + return CartResponse.from_entity(entity) \ No newline at end of file diff --git a/hw2/hw/shop_api/cart/store/__init__.py b/hw2/hw/shop_api/cart/store/__init__.py new file mode 100644 index 00000000..e14c47c2 --- /dev/null +++ b/hw2/hw/shop_api/cart/store/__init__.py @@ -0,0 +1,13 @@ +from .models import CartItemInfo, CartEntity, CartInfo +from .queries import add, delete, get_many, get_one, create + +__all__ = [ + "CartEntity", + "CartInfo", + "CartItemInfo", + "add", + "delete", + "get_many", + "get_one", + "create", +] diff --git a/hw2/hw/shop_api/cart/store/models.py b/hw2/hw/shop_api/cart/store/models.py new file mode 100644 index 00000000..edc7bdda --- /dev/null +++ b/hw2/hw/shop_api/cart/store/models.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class CartItemInfo: + id: int + name: str + quantity: int + available: bool + + +@dataclass(slots=True) +class CartInfo: + items: list[CartItemInfo] + price: float + + +@dataclass(slots=True) +class CartEntity: + id: int + info: CartInfo diff --git a/hw2/hw/shop_api/cart/store/queries.py b/hw2/hw/shop_api/cart/store/queries.py new file mode 100644 index 00000000..36a14d32 --- /dev/null +++ b/hw2/hw/shop_api/cart/store/queries.py @@ -0,0 +1,89 @@ +from logging import info +from typing import Iterable + +from shop_api.item.store.models import ItemEntity +from shop_api.cart.store.models import CartEntity, CartInfo, CartItemInfo + + +_data: dict[int, CartInfo] = {} + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_id_generator = int_id_generator() + + +def create() -> CartEntity: + _id = next(_id_generator) + info = CartInfo(items=[], price=0.0) + _data[_id] = info + return CartEntity(id=_id, info=info) + + +def add(cart_id: int, item_entity: ItemEntity) -> CartEntity: + cart_info = _data[cart_id] + for ci in cart_info.items: + if ci.id == item_entity.id: + ci.quantity += 1 + ci.available = not item_entity.info.deleted + break + else: + cart_info.items.append( + CartItemInfo( + id=item_entity.id, + name=item_entity.info.name, + quantity=1, + available=not item_entity.info.deleted, + ) + ) + cart_info.price += item_entity.info.price + _data[cart_id] = cart_info + + return CartEntity(id=cart_id, info=cart_info) + + +def delete(id: int) -> None: + if id in _data: + del _data[id] + + +def get_one(id: int) -> CartEntity | None: + if id not in _data: + return None + + return CartEntity(id=id, info=_data[id]) + + +def get_many( + offset: int = 0, + limit: int = 10, + min_price: float = None, + max_price: float = None, + min_quantity: int = None, + max_quantity: int = None, +) -> Iterable[CartEntity]: + curr = 0 + for id, info in _data.items(): + if min_price is not None and min_price > info.price: + continue + if max_price is not None and max_price < info.price: + continue + + sum_quantity = 0 + for item in info.items: + sum_quantity += item.quantity + + if min_quantity is not None and min_quantity > sum_quantity: + continue + if max_quantity is not None and max_quantity < sum_quantity: + continue + + if offset <= curr < offset + limit: + yield CartEntity(id, info) + + curr += 1 diff --git a/hw2/hw/shop_api/item/contracts.py b/hw2/hw/shop_api/item/contracts.py new file mode 100644 index 00000000..6d516d31 --- /dev/null +++ b/hw2/hw/shop_api/item/contracts.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from shop_api.item.store.models import ItemEntity, ItemInfo, PatchItemInfo + + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool + + @staticmethod + def from_entity(entity: ItemEntity) -> ItemResponse: + return ItemResponse( + id=entity.id, + name=entity.info.name, + price=entity.info.price, + deleted=entity.info.deleted, + ) + + +class ItemRequest(BaseModel): + name: str + price: float + deleted: bool = False + + def as_item_info(self) -> ItemInfo: + return ItemInfo(name=self.name, price=self.price, deleted=self.deleted) + + +class PatchItemRequest(BaseModel): + name: str | None = None + price: float | None = None + + model_config = ConfigDict(extra="forbid") + + def as_patch_item_info(self) -> PatchItemInfo: + return PatchItemInfo(name=self.name, price=self.price) diff --git a/hw2/hw/shop_api/item/routers.py b/hw2/hw/shop_api/item/routers.py new file mode 100644 index 00000000..d95d39d2 --- /dev/null +++ b/hw2/hw/shop_api/item/routers.py @@ -0,0 +1,112 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + + +from shop_api.item import store +from shop_api.item.contracts import ItemRequest, ItemResponse, PatchItemRequest + + +router = APIRouter(prefix="/item") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_item(info: ItemRequest, response: Response) -> ItemResponse: + entity = store.add(info.as_item_info()) + + response.headers["location"] = f"/item/{entity.id}" + return ItemResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested item as one was not found", + }, + }, +) +async def get_item_by_id(id: int): + entity = store.get_one(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.get("/") +async def get_item_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + show_deleted: Annotated[bool, Query()] | None = None, +): + return [ + ItemResponse.from_entity(e) + for e in store.get_many(offset, limit, min_price, max_price, show_deleted) + ] + + +@router.patch( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def patch_item(id: int, info: PatchItemRequest) -> ItemResponse: + entity = store.patch(id, info.as_patch_item_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.put( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully updated or upserted item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def put_item( + id: int, + info: ItemRequest, +) -> ItemResponse: + entity = store.update(id, info.as_item_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_item(id: int) -> Response: + store.delete(id) + return Response("") diff --git a/hw2/hw/shop_api/item/store/__init__.py b/hw2/hw/shop_api/item/store/__init__.py new file mode 100644 index 00000000..bf69aa56 --- /dev/null +++ b/hw2/hw/shop_api/item/store/__init__.py @@ -0,0 +1,14 @@ +from .models import PatchItemInfo, ItemEntity, ItemInfo +from .queries import add, delete, get_many, get_one, patch, update + +__all__ = [ + "ItemEntity", + "ItemInfo", + "PatchItemInfo", + "add", + "delete", + "get_many", + "get_one", + "update", + "patch", +] diff --git a/hw2/hw/shop_api/item/store/models.py b/hw2/hw/shop_api/item/store/models.py new file mode 100644 index 00000000..22bb1735 --- /dev/null +++ b/hw2/hw/shop_api/item/store/models.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class ItemInfo: + name: str + price: float + deleted: bool + + +@dataclass(slots=True) +class ItemEntity: + id: int + info: ItemInfo + + +@dataclass(slots=True) +class PatchItemInfo: + name: str | None = None + price: float | None = None \ No newline at end of file diff --git a/hw2/hw/shop_api/item/store/queries.py b/hw2/hw/shop_api/item/store/queries.py new file mode 100644 index 00000000..eff87679 --- /dev/null +++ b/hw2/hw/shop_api/item/store/queries.py @@ -0,0 +1,80 @@ +from typing import Iterable + +from shop_api.item.store.models import ItemEntity, ItemInfo, PatchItemInfo + + +_data: dict[int, ItemInfo] = {} + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_id_generator = int_id_generator() + + +def add(info: ItemInfo) -> ItemEntity: + _id = next(_id_generator) + _data[_id] = info + + return ItemEntity(_id, info) + + +def delete(id: int) -> None: + if id in _data: + del _data[id] + + +def get_one(id: int) -> ItemEntity | None: + if id not in _data: + return None + + return ItemEntity(id=id, info=_data[id]) + + +def get_many( + offset: int = 0, + limit: int = 10, + min_price: float = None, + max_price: float = None, + show_deleted: bool = False, +) -> Iterable[ItemEntity]: + curr = 0 + for id, info in _data.items(): + if min_price is not None and min_price > info.price: + continue + if max_price is not None and max_price < info.price: + continue + + if not show_deleted and info.deleted: + continue + + if offset <= curr < offset + limit: + yield ItemEntity(id, info) + + curr += 1 + + +def update(id: int, info: ItemInfo) -> ItemEntity | None: + if id not in _data: + return None + + _data[id] = info + + return ItemEntity(id=id, info=info) + + +def patch(id: int, patch_info: PatchItemInfo) -> ItemEntity | None: + if id not in _data: + return None + + if patch_info.name is not None: + _data[id].name = patch_info.name + + if patch_info.price is not None: + _data[id].price = patch_info.price + + return ItemEntity(id=id, info=_data[id]) diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..e9d946d9 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,14 @@ from fastapi import FastAPI +from shop_api.cart.routers import router as cart +from shop_api.item.routers import router as item + app = FastAPI(title="Shop API") + +app.include_router(cart) +app.include_router(item) + +@app.get("/") +async def root(): + return {"message": "API Shop is running"} + diff --git a/hw2/hw/shop_api/uvicorn b/hw2/hw/shop_api/uvicorn new file mode 100644 index 00000000..e69de29b From df445c1559018fb771c29aabc50ef243782583fb Mon Sep 17 00:00:00 2001 From: sidoine Date: Wed, 8 Oct 2025 18:03:28 +0300 Subject: [PATCH 03/48] Managing Docker tasks and monitoring Prometheus and Grafana --- hw3/grpc_example/README.md | 26 ++ hw3/grpc_example/__init__.py | 0 hw3/grpc_example/example_client.py | 25 ++ hw3/grpc_example/example_service.py | 25 ++ hw3/grpc_example/proto/ping.proto | 16 + hw3/grpc_example/requirements.txt | 1 + hw3/hw/Dockerfile | 24 ++ hw3/hw/GRAFANA-IMG/Grafana-total-request.PNG | Bin 0 -> 138212 bytes hw3/hw/GRAFANA-IMG/prometteuse.PNG | Bin 0 -> 38460 bytes .../request duration seocnd sum.PNG | Bin 0 -> 195625 bytes hw3/hw/README.md | 123 ++++++++ hw3/hw/__init__.py | 0 hw3/hw/docker-compose.yml | 30 ++ hw3/hw/python | 0 hw3/hw/requirements.txt | 11 + hw3/hw/settings/prometheus/prometheus.yml | 10 + hw3/hw/shop_api/__init__.py | 0 hw3/hw/shop_api/cart/contracts.py | 38 +++ hw3/hw/shop_api/cart/routers.py | 72 +++++ hw3/hw/shop_api/cart/store/__init__.py | 13 + hw3/hw/shop_api/cart/store/models.py | 21 ++ hw3/hw/shop_api/cart/store/queries.py | 89 ++++++ hw3/hw/shop_api/item/contracts.py | 40 +++ hw3/hw/shop_api/item/routers.py | 112 +++++++ hw3/hw/shop_api/item/store/__init__.py | 14 + hw3/hw/shop_api/item/store/models.py | 20 ++ hw3/hw/shop_api/item/store/queries.py | 80 +++++ hw3/hw/shop_api/main.py | 24 ++ hw3/hw/shop_api/uvicorn | 0 hw3/hw/test_homework2.py | 284 ++++++++++++++++++ hw3/rest_example/README.md | 3 + hw3/rest_example/__init__.py | 0 hw3/rest_example/api/__init__.py | 0 hw3/rest_example/api/pokemon/__init__.py | 9 + hw3/rest_example/api/pokemon/contracts.py | 41 +++ hw3/rest_example/api/pokemon/routes.py | 119 ++++++++ hw3/rest_example/main.py | 7 + hw3/rest_example/requirements.txt | 1 + hw3/rest_example/store/__init__.py | 15 + hw3/rest_example/store/models.py | 19 ++ hw3/rest_example/store/queries.py | 75 +++++ hw3/ws_example/README.md | 19 ++ hw3/ws_example/__init__.py | 0 hw3/ws_example/client.py | 6 + hw3/ws_example/requirements.txt | 2 + hw3/ws_example/server.py | 46 +++ 46 files changed, 1460 insertions(+) create mode 100644 hw3/grpc_example/README.md create mode 100644 hw3/grpc_example/__init__.py create mode 100644 hw3/grpc_example/example_client.py create mode 100644 hw3/grpc_example/example_service.py create mode 100644 hw3/grpc_example/proto/ping.proto create mode 100644 hw3/grpc_example/requirements.txt create mode 100644 hw3/hw/Dockerfile create mode 100644 hw3/hw/GRAFANA-IMG/Grafana-total-request.PNG create mode 100644 hw3/hw/GRAFANA-IMG/prometteuse.PNG create mode 100644 hw3/hw/GRAFANA-IMG/request duration seocnd sum.PNG create mode 100644 hw3/hw/README.md create mode 100644 hw3/hw/__init__.py create mode 100644 hw3/hw/docker-compose.yml create mode 100644 hw3/hw/python create mode 100644 hw3/hw/requirements.txt create mode 100644 hw3/hw/settings/prometheus/prometheus.yml create mode 100644 hw3/hw/shop_api/__init__.py create mode 100644 hw3/hw/shop_api/cart/contracts.py create mode 100644 hw3/hw/shop_api/cart/routers.py create mode 100644 hw3/hw/shop_api/cart/store/__init__.py create mode 100644 hw3/hw/shop_api/cart/store/models.py create mode 100644 hw3/hw/shop_api/cart/store/queries.py create mode 100644 hw3/hw/shop_api/item/contracts.py create mode 100644 hw3/hw/shop_api/item/routers.py create mode 100644 hw3/hw/shop_api/item/store/__init__.py create mode 100644 hw3/hw/shop_api/item/store/models.py create mode 100644 hw3/hw/shop_api/item/store/queries.py create mode 100644 hw3/hw/shop_api/main.py create mode 100644 hw3/hw/shop_api/uvicorn create mode 100644 hw3/hw/test_homework2.py create mode 100644 hw3/rest_example/README.md create mode 100644 hw3/rest_example/__init__.py create mode 100644 hw3/rest_example/api/__init__.py create mode 100644 hw3/rest_example/api/pokemon/__init__.py create mode 100644 hw3/rest_example/api/pokemon/contracts.py create mode 100644 hw3/rest_example/api/pokemon/routes.py create mode 100644 hw3/rest_example/main.py create mode 100644 hw3/rest_example/requirements.txt create mode 100644 hw3/rest_example/store/__init__.py create mode 100644 hw3/rest_example/store/models.py create mode 100644 hw3/rest_example/store/queries.py create mode 100644 hw3/ws_example/README.md create mode 100644 hw3/ws_example/__init__.py create mode 100644 hw3/ws_example/client.py create mode 100644 hw3/ws_example/requirements.txt create mode 100644 hw3/ws_example/server.py diff --git a/hw3/grpc_example/README.md b/hw3/grpc_example/README.md new file mode 100644 index 00000000..b62bdbd2 --- /dev/null +++ b/hw3/grpc_example/README.md @@ -0,0 +1,26 @@ +# gRPC Example + +Идейно изначально мы описываем .proto файл + +Затем командой генерим код (для этого нам понадобится библиотечка из requirements.txt), который будет за нас отправлять и принимать такие сообщения: + +```sh +python3 -m grpc_tools.protoc \ + --proto_path=./hw2/grpc_example/proto/ \ + --python_out=./hw2/grpc_example \ + --grpc_python_out=./hw2/grpc_example \ + --pyi_out=./hw2/grpc_example \ + ping.proto +``` + +Затем используя сгенерированный код можно написать свой клиент или сервер, тут уже нвписаны примеры в example_service.py и example_client.py + +Чтобы запустить сервис и клиент можно использовать следующие команды: + +```sh +python3 -m hw2.grpc_example.example_service +``` + +```sh +python3 -m hw2.grpc_example.example_client +``` diff --git a/hw3/grpc_example/__init__.py b/hw3/grpc_example/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/grpc_example/example_client.py b/hw3/grpc_example/example_client.py new file mode 100644 index 00000000..2d520d8c --- /dev/null +++ b/hw3/grpc_example/example_client.py @@ -0,0 +1,25 @@ +import grpc + +import hw2.grpc_example.ping_pb2 as pb2 +import hw2.grpc_example.ping_pb2_grpc as pb2_grpc + + +def message_from_input_generator(): + while True: + message = input() + + if not message: + return + + yield pb2.PingRequest(message=message) + + +if __name__ == "__main__": + with grpc.insecure_channel("localhost:50051") as channel: + stub = pb2_grpc.ExampleStub(channel) + + response = stub.Ping(pb2.PingRequest(message="message lol")) + print(response) + + for response in stub.PingStream(message_from_input_generator()): + print(response) diff --git a/hw3/grpc_example/example_service.py b/hw3/grpc_example/example_service.py new file mode 100644 index 00000000..8eaa9207 --- /dev/null +++ b/hw3/grpc_example/example_service.py @@ -0,0 +1,25 @@ +from concurrent import futures +from typing import Iterable + +import grpc + +import hw2.grpc_example.ping_pb2 as pb2 +import hw2.grpc_example.ping_pb2_grpc as pb2_grpc + + +class ExampleService(pb2_grpc.ExampleServicer): + def Ping(self, request: pb2.PingRequest, context): + return pb2.PongResponse(message=request.message) + + def PingStream(self, request_iterator: Iterable[pb2.PingRequest], context): + for message in request_iterator: + yield pb2.PongResponse(message=message.message) + + +if __name__ == "__main__": + print("running server") + server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) + pb2_grpc.add_ExampleServicer_to_server(ExampleService(), server) + server.add_insecure_port("[::]:50051") + server.start() + server.wait_for_termination() diff --git a/hw3/grpc_example/proto/ping.proto b/hw3/grpc_example/proto/ping.proto new file mode 100644 index 00000000..7c09b204 --- /dev/null +++ b/hw3/grpc_example/proto/ping.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package example; + +service Example { + rpc Ping(PingRequest) returns (PongResponse); + rpc PingStream(stream PingRequest) returns (stream PongResponse); +} + +message PingRequest { + string message = 1; +} + +message PongResponse { + string message = 1; +} \ No newline at end of file diff --git a/hw3/grpc_example/requirements.txt b/hw3/grpc_example/requirements.txt new file mode 100644 index 00000000..420e91c3 --- /dev/null +++ b/hw3/grpc_example/requirements.txt @@ -0,0 +1 @@ +grpcio-tools>=1.75.0 diff --git a/hw3/hw/Dockerfile b/hw3/hw/Dockerfile new file mode 100644 index 00000000..b498d04e --- /dev/null +++ b/hw3/hw/Dockerfile @@ -0,0 +1,24 @@ +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 +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"] \ No newline at end of file diff --git a/hw3/hw/GRAFANA-IMG/Grafana-total-request.PNG b/hw3/hw/GRAFANA-IMG/Grafana-total-request.PNG new file mode 100644 index 0000000000000000000000000000000000000000..1a37d7daef3d3aa3c04bfb0706e8d6847a086f74 GIT binary patch literal 138212 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�w2wxts*yrbI| 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 literal 0 HcmV?d00001 diff --git a/hw3/hw/GRAFANA-IMG/prometteuse.PNG b/hw3/hw/GRAFANA-IMG/prometteuse.PNG new file mode 100644 index 0000000000000000000000000000000000000000..d30e55592c36763d423e389a2f5800a3b37713e8 GIT binary patch literal 38460 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%0ASSExuN+{E1pU**08g0zCKJ1Ois45OaJ_BPM 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 literal 0 HcmV?d00001 diff --git a/hw3/hw/README.md b/hw3/hw/README.md new file mode 100644 index 00000000..ba9f23c8 --- /dev/null +++ b/hw3/hw/README.md @@ -0,0 +1,123 @@ +# ДЗ + +## Задание - REST API (3 балла) + +Реализовать REST + RPC API для выдуманного интернет магазина. + +Тесты завязаны на объект `app = FastAPI(title="Shop API")` из файла [hw2/hw/shop_api/main.py](./shop_api/main.py), поэтому реализовывайте его используя его + +Ресурсы: + +- корзина (cart) + + Пример структуры ресурса: + + ```json + { + "id": 123, // идентификатор корзины + "items": [ // список товаров в корзине + { + "id": 1, // id товара + "name": "Туалетная бумага \"Поцелуй\", рулон", // название + "quantity": 3, // количество товара в корзине + "available": true // доступе ли (не удален ли) товар + }, + { + "id": 535, + "name": "Золотая цепочка \"Abendsonne\"", + "quantity": 1, + "available": false, + }, + ], + "price": 234.4 // общая сумма заказа + } + ``` + +- товар (item) + + Пример структуры ресурса: + + ```json + { + "id": 321, // идентификатор товара + "name": "Молоко \"Буреночка\" 1л.", // наименование товара + "price": 159.99, // цена товара + "deleted": false // удален ли товар, по умолчанию false + } + ``` + +Запросы для реализации: + +- cart + - `POST cart` - создание, работает как RPC, не принимает тело, возвращает + идентификатор + - `GET /cart/{id}` - получение корзины по `id` + - `GET /cart` - получение списка корзин с query-параметрами + - `offset` - неотрицательное целое число, смещение по списку (опционально, + по-умолчанию 0) + - `limit` - положительное целое число, ограничение на количество + (опционально, по-умолчанию 10) + - `min_price` - число с плавающей запятой, минимальная цена включительно + (опционально, если нет, не учитывает в фильтре) + - `max_price` - число с плавающей запятой, максимальная цена включительно + (опционально, если нет, не учитывает в фильтре) + - `min_quantity` - неотрицательное целое число, минимальное общее число + товаров включительно (опционально, если нет, не учитывается в фильтре) + - `max_quantity` - неотрицательное целое число, максимальное общее число + товаров включительно (опционально, если нет, не учитывается в фильтре) + - `POST /cart/{cart_id}/add/{item_id}` - добавление в корзину с `cart_id` + предмета с `item_id`, если товар уже есть, то увеличивается его количество +- item + - `POST /item` - добавление нового товара + - `GET /item/{id}` - получение товара по `id` + - `GET /item` - получение списка товаров с query-параметрами + - `offset` - неотрицательное целое число, смещение по списку (опционально, + по-умолчанию 0) + - `limit` - положительное целое число, ограничение на количество + (опционально, по-умолчанию 10) + - `min_price` - число с плавающей запятой, минимальная цена (опционально, + если нет, не учитывает в фильтре) + - `max_price` - число с плавающей запятой, максимальная цена (опционально, + если нет, не учитывает в фильтре) + - `show_deleted` - булевая переменная, показывать ли удаленные товары (по + умолчанию `False`) + - `PUT /item/{id}` - замена товара по `id` (создание запрещено, только замена + существующего) + - `PATCH /item/{id}` - частичное обновление товара по `id` (разрешено менять + все поля, кроме `deleted`) + - `DELETE /item/{id}` - удаление товара по `id` (товар помечается как + удаленный) + +Способ хранение данных на усмотрение. + +Более подробные детали и требования к работе методов смотрите в тестах. +Модификация тестов при потребности допускается (но не смысловая). + +Чтобы запустить тесты только для этого задания вызовите: + +```sh +pytest -vv --showlocals --strict ./hw2/test_homework_2_1.py +``` + +Если получаете ошибку на подобии `No module named 'shop_api'` +Понадобится еще такая команда, после которой можно запускать тесты: + +```sh +export PYTHONPATH=${PWD}/hw2/hw +``` + +## Доп. Задание - WebSocket (+ доп балл) + +Реализовать чат для пользователей в отдельных комнатах (в примере один на всех). + +Пользователи подключаются к чату по WebSocket ручке `/chat/{chat_name}`. +Пользователи, которые ввели один и тот же `chat_name` буду подключены к одному +чату (то есть будут получать сообщения друг от друга). Пользователи не +подключенные к диалогу не будут получать сообщения. + +Сообщение - текст в теле сообщения от клиента. Сервер должен broadcast'ить +сообщения на других пользователей в своем чате. Каждому клиенту сервер +присваивает случайное имя и дополняет каждое сообщение именем пользователя в +начале в следующем виде: `{username} :: {message}`. + +Если делаете его, напишите, пожалуйста, прямо в PR-e об этом. Мне будет сильно проще это заметить<3 diff --git a/hw3/hw/__init__.py b/hw3/hw/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/hw/docker-compose.yml b/hw3/hw/docker-compose.yml new file mode 100644 index 00000000..b990f773 --- /dev/null +++ b/hw3/hw/docker-compose.yml @@ -0,0 +1,30 @@ +services: + local: + build: + context: . + restart: always + ports: + - 8080:8080 + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + depends_on: + - prometheus + + 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 + depends_on: + - local diff --git a/hw3/hw/python b/hw3/hw/python new file mode 100644 index 00000000..e69de29b diff --git a/hw3/hw/requirements.txt b/hw3/hw/requirements.txt new file mode 100644 index 00000000..5e709f64 --- /dev/null +++ b/hw3/hw/requirements.txt @@ -0,0 +1,11 @@ +# Основные зависимости для ASGI приложения +fastapi>=0.117.1 +uvicorn>=0.24.0 + +# Зависимости для тестирования +pytest>=7.4.0 +pytest-asyncio>=0.21.0 +httpx>=0.27.2 +Faker>=37.8.0 + +prometheus_fastapi_instrumentator diff --git a/hw3/hw/settings/prometheus/prometheus.yml b/hw3/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..e443970b --- /dev/null +++ b/hw3/hw/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-api + metrics_path: /metrics + static_configs: + - targets: + - local:8080 diff --git a/hw3/hw/shop_api/__init__.py b/hw3/hw/shop_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/hw/shop_api/cart/contracts.py b/hw3/hw/shop_api/cart/contracts.py new file mode 100644 index 00000000..ae1c2509 --- /dev/null +++ b/hw3/hw/shop_api/cart/contracts.py @@ -0,0 +1,38 @@ +from __future__ import annotations +from typing import Any + +from pydantic import BaseModel + +from shop_api.cart.store.models import CartEntity, CartInfo, CartItemInfo + + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + + +# class CartItemRequest(BaseModel): +# items: list[CartItemInfo] +# price: float + +# def as_cart_info(self) -> CartInfo: +# items = [CartItemInfo(**item.dict()) for item in self.items] +# # price = +# return CartInfo(items=items, price=self.price) + + + +class CartResponse(BaseModel): + id: int + items: list[CartItemInfo] + price: float + + @staticmethod + def from_entity(entity: CartEntity) -> CartResponse: + return CartResponse( + id=entity.id, + items=entity.info.items, + price=entity.info.price, + ) \ No newline at end of file diff --git a/hw3/hw/shop_api/cart/routers.py b/hw3/hw/shop_api/cart/routers.py new file mode 100644 index 00000000..d510513c --- /dev/null +++ b/hw3/hw/shop_api/cart/routers.py @@ -0,0 +1,72 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + + +from shop_api.cart import store +from shop_api.cart.contracts import CartResponse + +import shop_api +import shop_api.item +import shop_api.item.store + + +router = APIRouter(prefix="/cart") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_cart(response: Response) -> CartResponse: + entity = store.create() + + response.headers["location"] = f"/cart/{entity.id}" + return CartResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested cart", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested cart as one was not found", + }, + }, +) +async def get_cart_by_id(id: int): + entity = store.get_one(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /cart/{id} was not found", + ) + + return CartResponse.from_entity(entity) + + +@router.get("/") +async def get_cart_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + min_quantity: Annotated[NonNegativeFloat, Query()] | None = None, + max_quantity: Annotated[NonNegativeFloat, Query()] | None = None, +): + return [ + CartResponse.from_entity(e) + for e in store.get_many( + offset, limit, min_price, max_price, min_quantity, max_quantity + ) + ] + +@router.post("/{cart_id}/add/{item_id}") +async def add_to_cart(cart_id: int, item_id: int): + item_entity = shop_api.item.store.get_one(item_id) + + entity = store.add(cart_id, item_entity) + + return CartResponse.from_entity(entity) \ No newline at end of file diff --git a/hw3/hw/shop_api/cart/store/__init__.py b/hw3/hw/shop_api/cart/store/__init__.py new file mode 100644 index 00000000..e14c47c2 --- /dev/null +++ b/hw3/hw/shop_api/cart/store/__init__.py @@ -0,0 +1,13 @@ +from .models import CartItemInfo, CartEntity, CartInfo +from .queries import add, delete, get_many, get_one, create + +__all__ = [ + "CartEntity", + "CartInfo", + "CartItemInfo", + "add", + "delete", + "get_many", + "get_one", + "create", +] diff --git a/hw3/hw/shop_api/cart/store/models.py b/hw3/hw/shop_api/cart/store/models.py new file mode 100644 index 00000000..edc7bdda --- /dev/null +++ b/hw3/hw/shop_api/cart/store/models.py @@ -0,0 +1,21 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class CartItemInfo: + id: int + name: str + quantity: int + available: bool + + +@dataclass(slots=True) +class CartInfo: + items: list[CartItemInfo] + price: float + + +@dataclass(slots=True) +class CartEntity: + id: int + info: CartInfo diff --git a/hw3/hw/shop_api/cart/store/queries.py b/hw3/hw/shop_api/cart/store/queries.py new file mode 100644 index 00000000..36a14d32 --- /dev/null +++ b/hw3/hw/shop_api/cart/store/queries.py @@ -0,0 +1,89 @@ +from logging import info +from typing import Iterable + +from shop_api.item.store.models import ItemEntity +from shop_api.cart.store.models import CartEntity, CartInfo, CartItemInfo + + +_data: dict[int, CartInfo] = {} + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_id_generator = int_id_generator() + + +def create() -> CartEntity: + _id = next(_id_generator) + info = CartInfo(items=[], price=0.0) + _data[_id] = info + return CartEntity(id=_id, info=info) + + +def add(cart_id: int, item_entity: ItemEntity) -> CartEntity: + cart_info = _data[cart_id] + for ci in cart_info.items: + if ci.id == item_entity.id: + ci.quantity += 1 + ci.available = not item_entity.info.deleted + break + else: + cart_info.items.append( + CartItemInfo( + id=item_entity.id, + name=item_entity.info.name, + quantity=1, + available=not item_entity.info.deleted, + ) + ) + cart_info.price += item_entity.info.price + _data[cart_id] = cart_info + + return CartEntity(id=cart_id, info=cart_info) + + +def delete(id: int) -> None: + if id in _data: + del _data[id] + + +def get_one(id: int) -> CartEntity | None: + if id not in _data: + return None + + return CartEntity(id=id, info=_data[id]) + + +def get_many( + offset: int = 0, + limit: int = 10, + min_price: float = None, + max_price: float = None, + min_quantity: int = None, + max_quantity: int = None, +) -> Iterable[CartEntity]: + curr = 0 + for id, info in _data.items(): + if min_price is not None and min_price > info.price: + continue + if max_price is not None and max_price < info.price: + continue + + sum_quantity = 0 + for item in info.items: + sum_quantity += item.quantity + + if min_quantity is not None and min_quantity > sum_quantity: + continue + if max_quantity is not None and max_quantity < sum_quantity: + continue + + if offset <= curr < offset + limit: + yield CartEntity(id, info) + + curr += 1 diff --git a/hw3/hw/shop_api/item/contracts.py b/hw3/hw/shop_api/item/contracts.py new file mode 100644 index 00000000..6d516d31 --- /dev/null +++ b/hw3/hw/shop_api/item/contracts.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from shop_api.item.store.models import ItemEntity, ItemInfo, PatchItemInfo + + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool + + @staticmethod + def from_entity(entity: ItemEntity) -> ItemResponse: + return ItemResponse( + id=entity.id, + name=entity.info.name, + price=entity.info.price, + deleted=entity.info.deleted, + ) + + +class ItemRequest(BaseModel): + name: str + price: float + deleted: bool = False + + def as_item_info(self) -> ItemInfo: + return ItemInfo(name=self.name, price=self.price, deleted=self.deleted) + + +class PatchItemRequest(BaseModel): + name: str | None = None + price: float | None = None + + model_config = ConfigDict(extra="forbid") + + def as_patch_item_info(self) -> PatchItemInfo: + return PatchItemInfo(name=self.name, price=self.price) diff --git a/hw3/hw/shop_api/item/routers.py b/hw3/hw/shop_api/item/routers.py new file mode 100644 index 00000000..d95d39d2 --- /dev/null +++ b/hw3/hw/shop_api/item/routers.py @@ -0,0 +1,112 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + + +from shop_api.item import store +from shop_api.item.contracts import ItemRequest, ItemResponse, PatchItemRequest + + +router = APIRouter(prefix="/item") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_item(info: ItemRequest, response: Response) -> ItemResponse: + entity = store.add(info.as_item_info()) + + response.headers["location"] = f"/item/{entity.id}" + return ItemResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested item as one was not found", + }, + }, +) +async def get_item_by_id(id: int): + entity = store.get_one(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.get("/") +async def get_item_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + show_deleted: Annotated[bool, Query()] | None = None, +): + return [ + ItemResponse.from_entity(e) + for e in store.get_many(offset, limit, min_price, max_price, show_deleted) + ] + + +@router.patch( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def patch_item(id: int, info: PatchItemRequest) -> ItemResponse: + entity = store.patch(id, info.as_patch_item_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.put( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully updated or upserted item", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def put_item( + id: int, + info: ItemRequest, +) -> ItemResponse: + entity = store.update(id, info.as_item_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_item(id: int) -> Response: + store.delete(id) + return Response("") diff --git a/hw3/hw/shop_api/item/store/__init__.py b/hw3/hw/shop_api/item/store/__init__.py new file mode 100644 index 00000000..bf69aa56 --- /dev/null +++ b/hw3/hw/shop_api/item/store/__init__.py @@ -0,0 +1,14 @@ +from .models import PatchItemInfo, ItemEntity, ItemInfo +from .queries import add, delete, get_many, get_one, patch, update + +__all__ = [ + "ItemEntity", + "ItemInfo", + "PatchItemInfo", + "add", + "delete", + "get_many", + "get_one", + "update", + "patch", +] diff --git a/hw3/hw/shop_api/item/store/models.py b/hw3/hw/shop_api/item/store/models.py new file mode 100644 index 00000000..22bb1735 --- /dev/null +++ b/hw3/hw/shop_api/item/store/models.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class ItemInfo: + name: str + price: float + deleted: bool + + +@dataclass(slots=True) +class ItemEntity: + id: int + info: ItemInfo + + +@dataclass(slots=True) +class PatchItemInfo: + name: str | None = None + price: float | None = None \ No newline at end of file diff --git a/hw3/hw/shop_api/item/store/queries.py b/hw3/hw/shop_api/item/store/queries.py new file mode 100644 index 00000000..eff87679 --- /dev/null +++ b/hw3/hw/shop_api/item/store/queries.py @@ -0,0 +1,80 @@ +from typing import Iterable + +from shop_api.item.store.models import ItemEntity, ItemInfo, PatchItemInfo + + +_data: dict[int, ItemInfo] = {} + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_id_generator = int_id_generator() + + +def add(info: ItemInfo) -> ItemEntity: + _id = next(_id_generator) + _data[_id] = info + + return ItemEntity(_id, info) + + +def delete(id: int) -> None: + if id in _data: + del _data[id] + + +def get_one(id: int) -> ItemEntity | None: + if id not in _data: + return None + + return ItemEntity(id=id, info=_data[id]) + + +def get_many( + offset: int = 0, + limit: int = 10, + min_price: float = None, + max_price: float = None, + show_deleted: bool = False, +) -> Iterable[ItemEntity]: + curr = 0 + for id, info in _data.items(): + if min_price is not None and min_price > info.price: + continue + if max_price is not None and max_price < info.price: + continue + + if not show_deleted and info.deleted: + continue + + if offset <= curr < offset + limit: + yield ItemEntity(id, info) + + curr += 1 + + +def update(id: int, info: ItemInfo) -> ItemEntity | None: + if id not in _data: + return None + + _data[id] = info + + return ItemEntity(id=id, info=info) + + +def patch(id: int, patch_info: PatchItemInfo) -> ItemEntity | None: + if id not in _data: + return None + + if patch_info.name is not None: + _data[id].name = patch_info.name + + if patch_info.price is not None: + _data[id].price = patch_info.price + + return ItemEntity(id=id, info=_data[id]) diff --git a/hw3/hw/shop_api/main.py b/hw3/hw/shop_api/main.py new file mode 100644 index 00000000..58b419d4 --- /dev/null +++ b/hw3/hw/shop_api/main.py @@ -0,0 +1,24 @@ +from fastapi import FastAPI + +from shop_api.cart.routers import router as cart +from shop_api.item.routers import router as item + +from prometheus_fastapi_instrumentator import Instrumentator + + +app = FastAPI(title="Shop API") + +app.include_router(cart) +app.include_router(item) + +# Initialise l'instrumentation +instrumentator = Instrumentator() + +# Instrumente l'application et expose le point de terminaison /metrics +instrumentator.instrument(app) +instrumentator.expose(app) + +@app.get("/") +async def root(): + return {"message": "API Shop is running"} + diff --git a/hw3/hw/shop_api/uvicorn b/hw3/hw/shop_api/uvicorn new file mode 100644 index 00000000..e69de29b diff --git a/hw3/hw/test_homework2.py b/hw3/hw/test_homework2.py new file mode 100644 index 00000000..60a1f36a --- /dev/null +++ b/hw3/hw/test_homework2.py @@ -0,0 +1,284 @@ +from http import HTTPStatus +from typing import Any +from uuid import uuid4 + +import pytest +from faker import Faker +from fastapi.testclient import TestClient + +from shop_api.main import app + +client = TestClient(app) +faker = Faker() + + +@pytest.fixture() +def existing_empty_cart_id() -> int: + return client.post("/cart").json()["id"] + + +@pytest.fixture(scope="session") +def existing_items() -> list[int]: + items = [ + { + "name": f"Тестовый товар {i}", + "price": faker.pyfloat(positive=True, min_value=10.0, max_value=500.0), + } + for i in range(10) + ] + + return [client.post("/item", json=item).json()["id"] for item in items] + + +@pytest.fixture(scope="session", autouse=True) +def existing_not_empty_carts(existing_items: list[int]) -> list[int]: + carts = [] + + for i in range(20): + cart_id: int = client.post("/cart").json()["id"] + for item_id in faker.random_elements(existing_items, unique=False, length=i): + client.post(f"/cart/{cart_id}/add/{item_id}") + + carts.append(cart_id) + + return carts + + +@pytest.fixture() +def existing_not_empty_cart_id( + existing_empty_cart_id: int, + existing_items: list[int], +) -> int: + for item_id in faker.random_elements(existing_items, unique=False, length=3): + client.post(f"/cart/{existing_empty_cart_id}/add/{item_id}") + + return existing_empty_cart_id + + +@pytest.fixture() +def existing_item() -> dict[str, Any]: + return client.post( + "/item", + json={ + "name": f"Тестовый товар {uuid4().hex}", + "price": faker.pyfloat(min_value=10.0, max_value=100.0), + }, + ).json() + + +@pytest.fixture() +def deleted_item(existing_item: dict[str, Any]) -> dict[str, Any]: + item_id = existing_item["id"] + client.delete(f"/item/{item_id}") + + existing_item["deleted"] = True + return existing_item + + +def test_post_cart() -> None: + response = client.post("/cart") + + assert response.status_code == HTTPStatus.CREATED + assert "location" in response.headers + assert "id" in response.json() + + +@pytest.mark.parametrize( + ("cart", "not_empty"), + [ + ("existing_empty_cart_id", False), + ("existing_not_empty_cart_id", True), + ], +) +def test_get_cart(request, cart: int, not_empty: bool) -> None: + cart_id = request.getfixturevalue(cart) + + response = client.get(f"/cart/{cart_id}") + + assert response.status_code == HTTPStatus.OK + response_json = response.json() + + len_items = len(response_json["items"]) + assert len_items > 0 if not_empty else len_items == 0 + + if not_empty: + price = 0 + + for item in response_json["items"]: + item_id = item["id"] + price += client.get(f"/item/{item_id}").json()["price"] * item["quantity"] + + assert response_json["price"] == pytest.approx(price, 1e-8) + else: + assert response_json["price"] == 0.0 + + +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({}, HTTPStatus.OK), + ({"offset": 1, "limit": 2}, HTTPStatus.OK), + ({"min_price": 1000.0}, HTTPStatus.OK), + ({"max_price": 20.0}, HTTPStatus.OK), + ({"min_quantity": 1}, HTTPStatus.OK), + ({"max_quantity": 0}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_cart_list(query: dict[str, Any], status_code: int): + response = client.get("/cart", params=query) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + + assert isinstance(data, list) + + if "min_price" in query: + assert all(item["price"] >= query["min_price"] for item in data) + + if "max_price" in query: + assert all(item["price"] <= query["max_price"] for item in data) + + quantity = sum(item["quantity"] for cart in data for item in cart["items"]) + + if "min_quantity" in query: + assert quantity >= query["min_quantity"] + + if "max_quantity" in query: + assert quantity <= query["max_quantity"] + + +def test_post_item() -> None: + item = {"name": "test item", "price": 9.99} + response = client.post("/item", json=item) + + assert response.status_code == HTTPStatus.CREATED + + data = response.json() + assert item["price"] == data["price"] + assert item["name"] == data["name"] + + +def test_get_item(existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = client.get(f"/item/{item_id}") + + assert response.status_code == HTTPStatus.OK + assert response.json() == existing_item + + +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({"offset": 2, "limit": 5}, HTTPStatus.OK), + ({"min_price": 5.0}, HTTPStatus.OK), + ({"max_price": 5.0}, HTTPStatus.OK), + ({"show_deleted": True}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_item_list(query: dict[str, Any], status_code: int) -> None: + response = client.get("/item", params=query) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + + assert isinstance(data, list) + + if "min_price" in query: + assert all(item["price"] >= query["min_price"] for item in data) + + if "max_price" in query: + assert all(item["price"] <= query["max_price"] for item in data) + + if "show_deleted" in query and query["show_deleted"] is False: + assert all(item["deleted"] is False for item in data) + + +@pytest.mark.parametrize( + ("body", "status_code"), + [ + ({}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"price": 9.99}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"name": "new name", "price": 9.99}, HTTPStatus.OK), + ], +) +def test_put_item( + existing_item: dict[str, Any], + body: dict[str, Any], + status_code: int, +) -> None: + item_id = existing_item["id"] + response = client.put(f"/item/{item_id}", json=body) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + new_item = existing_item.copy() + new_item.update(body) + assert response.json() == new_item + + +@pytest.mark.parametrize( + ("item", "body", "status_code"), + [ + ("deleted_item", {}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"name": "new name", "price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("existing_item", {}, HTTPStatus.OK), + ("existing_item", {"price": 9.99}, HTTPStatus.OK), + ("existing_item", {"name": "new name", "price": 9.99}, HTTPStatus.OK), + ( + "existing_item", + {"name": "new name", "price": 9.99, "odd": "value"}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ( + "existing_item", + {"name": "new name", "price": 9.99, "deleted": True}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ], +) +def test_patch_item(request, item: str, body: dict[str, Any], status_code: int) -> None: + item_data: dict[str, Any] = request.getfixturevalue(item) + item_id = item_data["id"] + response = client.patch(f"/item/{item_id}", json=body) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + patch_response_body = response.json() + + response = client.get(f"/item/{item_id}") + patched_item = response.json() + + assert patched_item == patch_response_body + + +def test_delete_item(existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + + response = client.get(f"/item/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK diff --git a/hw3/rest_example/README.md b/hw3/rest_example/README.md new file mode 100644 index 00000000..dc17981e --- /dev/null +++ b/hw3/rest_example/README.md @@ -0,0 +1,3 @@ +# REST API Example + +Пример REST API как пример из лекции, тут храним данные прямо в приложении (в оперативной памяти), т.к. код чисто для примера, не делайте так в продакшене, пожалуйста diff --git a/hw3/rest_example/__init__.py b/hw3/rest_example/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/rest_example/api/__init__.py b/hw3/rest_example/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/rest_example/api/pokemon/__init__.py b/hw3/rest_example/api/pokemon/__init__.py new file mode 100644 index 00000000..ccd625f1 --- /dev/null +++ b/hw3/rest_example/api/pokemon/__init__.py @@ -0,0 +1,9 @@ +from .contracts import PatchPokemonRequest, PokemonRequest, PokemonResponse +from .routes import router + +__all__ = [ + "PokemonResponse", + "PokemonRequest", + "PatchPokemonRequest", + "router", +] diff --git a/hw3/rest_example/api/pokemon/contracts.py b/hw3/rest_example/api/pokemon/contracts.py new file mode 100644 index 00000000..a985b15b --- /dev/null +++ b/hw3/rest_example/api/pokemon/contracts.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from hw2.rest_example.store.models import ( + PatchPokemonInfo, + PokemonEntity, + PokemonInfo, +) + + +class PokemonResponse(BaseModel): + id: int + name: str + published: bool + + @staticmethod + def from_entity(entity: PokemonEntity) -> PokemonResponse: + return PokemonResponse( + id=entity.id, + name=entity.info.name, + published=entity.info.published, + ) + + +class PokemonRequest(BaseModel): + name: str + published: bool + + def as_pokemon_info(self) -> PokemonInfo: + return PokemonInfo(name=self.name, published=self.published) + + +class PatchPokemonRequest(BaseModel): + name: str | None = None + published: bool | None = None + + model_config = ConfigDict(extra="forbid") + + def as_patch_pokemon_info(self) -> PatchPokemonInfo: + return PatchPokemonInfo(name=self.name, published=self.published) diff --git a/hw3/rest_example/api/pokemon/routes.py b/hw3/rest_example/api/pokemon/routes.py new file mode 100644 index 00000000..ab935c9a --- /dev/null +++ b/hw3/rest_example/api/pokemon/routes.py @@ -0,0 +1,119 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeInt, PositiveInt + +from hw2.rest_example import store + +from .contracts import ( + PatchPokemonRequest, + PokemonRequest, + PokemonResponse, +) + +router = APIRouter(prefix="/pokemon") + + +@router.get("/") +async def get_pokemon_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, +) -> list[PokemonResponse]: + return [PokemonResponse.from_entity(e) for e in store.get_many(offset, limit)] + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested pokemon", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested pokemon as one was not found", + }, + }, +) +async def get_pokemon_by_id(id: int) -> PokemonResponse: + entity = store.get_one(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /pokemon/{id} was not found", + ) + + return PokemonResponse.from_entity(entity) + + +@router.post( + "/", + status_code=HTTPStatus.CREATED, +) +async def post_pokemon(info: PokemonRequest, response: Response) -> PokemonResponse: + entity = store.add(info.as_pokemon_info()) + + # as REST states one should provide uri to newly created resource in location header + response.headers["location"] = f"/pokemon/{entity.id}" + + return PokemonResponse.from_entity(entity) + + +@router.patch( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched pokemon", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify pokemon as one was not found", + }, + }, +) +async def patch_pokemon(id: int, info: PatchPokemonRequest) -> PokemonResponse: + entity = store.patch(id, info.as_patch_pokemon_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /pokemon/{id} was not found", + ) + + return PokemonResponse.from_entity(entity) + + +@router.put( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully updated or upserted pokemon", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify pokemon as one was not found", + }, + } +) +async def put_pokemon( + id: int, + info: PokemonRequest, + upsert: Annotated[bool, Query()] = False, +) -> PokemonResponse: + entity = ( + store.upsert(id, info.as_pokemon_info()) + if upsert + else store.update(id, info.as_pokemon_info()) + ) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /pokemon/{id} was not found", + ) + + return PokemonResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_pokemon(id: int) -> Response: + store.delete(id) + return Response("") diff --git a/hw3/rest_example/main.py b/hw3/rest_example/main.py new file mode 100644 index 00000000..26a2cf80 --- /dev/null +++ b/hw3/rest_example/main.py @@ -0,0 +1,7 @@ +from fastapi import FastAPI + +from hw2.rest_example.api.pokemon import router + +app = FastAPI(title="Pokemon REST API Example") + +app.include_router(router) diff --git a/hw3/rest_example/requirements.txt b/hw3/rest_example/requirements.txt new file mode 100644 index 00000000..b66bec1e --- /dev/null +++ b/hw3/rest_example/requirements.txt @@ -0,0 +1 @@ +fastapi>=0.117.1 diff --git a/hw3/rest_example/store/__init__.py b/hw3/rest_example/store/__init__.py new file mode 100644 index 00000000..cb99d02a --- /dev/null +++ b/hw3/rest_example/store/__init__.py @@ -0,0 +1,15 @@ +from .models import PatchPokemonInfo, PokemonEntity, PokemonInfo +from .queries import add, delete, get_many, get_one, patch, update, upsert + +__all__ = [ + "PokemonEntity", + "PokemonInfo", + "PatchPokemonInfo", + "add", + "delete", + "get_many", + "get_one", + "update", + "upsert", + "patch", +] diff --git a/hw3/rest_example/store/models.py b/hw3/rest_example/store/models.py new file mode 100644 index 00000000..95cd40b9 --- /dev/null +++ b/hw3/rest_example/store/models.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class PokemonInfo: + name: str + published: bool + + +@dataclass(slots=True) +class PokemonEntity: + id: int + info: PokemonInfo + + +@dataclass(slots=True) +class PatchPokemonInfo: + name: str | None = None + published: bool | None = None diff --git a/hw3/rest_example/store/queries.py b/hw3/rest_example/store/queries.py new file mode 100644 index 00000000..959492d7 --- /dev/null +++ b/hw3/rest_example/store/queries.py @@ -0,0 +1,75 @@ +from typing import Iterable + +from hw2.rest_example.store.models import ( + PatchPokemonInfo, + PokemonEntity, + PokemonInfo, +) + +_data = dict[int, PokemonInfo]() + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_id_generator = int_id_generator() + + +def add(info: PokemonInfo) -> PokemonEntity: + _id = next(_id_generator) + _data[_id] = info + + return PokemonEntity(_id, info) + + +def delete(id: int) -> None: + if id in _data: + del _data[id] + + +def get_one(id: int) -> PokemonEntity | None: + if id not in _data: + return None + + return PokemonEntity(id=id, info=_data[id]) + + +def get_many(offset: int = 0, limit: int = 10) -> Iterable[PokemonEntity]: + curr = 0 + for id, info in _data.items(): + if offset <= curr < offset + limit: + yield PokemonEntity(id, info) + + curr += 1 + + +def update(id: int, info: PokemonInfo) -> PokemonEntity | None: + if id not in _data: + return None + + _data[id] = info + + return PokemonEntity(id=id, info=info) + + +def upsert(id: int, info: PokemonInfo) -> PokemonEntity: + _data[id] = info + + return PokemonEntity(id=id, info=info) + + +def patch(id: int, patch_info: PatchPokemonInfo) -> PokemonEntity | None: + if id not in _data: + return None + + if patch_info.name is not None: + _data[id].name = patch_info.name + + if patch_info.published is not None: + _data[id].published = patch_info.published + + return PokemonEntity(id=id, info=_data[id]) diff --git a/hw3/ws_example/README.md b/hw3/ws_example/README.md new file mode 100644 index 00000000..471f0d10 --- /dev/null +++ b/hw3/ws_example/README.md @@ -0,0 +1,19 @@ +# WebSocket Example + +Минимально рабочий пример WebSocket broadcaster'а как пример из лекции. Сервер принимает подключения от клиентов и рассылает сообщения всем подключенным одновременно. + +Важная особенность: сервер должен хранить активные WebSocket подключения непосредственно в оперативной памяти, из-за чего эта штука stateful. При перезапуске сервера все соединения потеряются, и горизонтальное масштабирование становится проблематичным без дополнительных решений. + +Для запуска сервера: + +```sh +uvicorn server:app --reload +``` + +Для запуска клиента в отдельном терминале: + +```sh +python client.py +``` + +Можно отправлять сообщения всем подключенным клиентам через POST запрос на `/publish`. diff --git a/hw3/ws_example/__init__.py b/hw3/ws_example/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw3/ws_example/client.py b/hw3/ws_example/client.py new file mode 100644 index 00000000..0af3441b --- /dev/null +++ b/hw3/ws_example/client.py @@ -0,0 +1,6 @@ +from websocket import create_connection + +ws = create_connection("ws://localhost:8000/subscribe") + +while True: + print(ws.recv()) diff --git a/hw3/ws_example/requirements.txt b/hw3/ws_example/requirements.txt new file mode 100644 index 00000000..3af9a713 --- /dev/null +++ b/hw3/ws_example/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.117.1 +websockets>=0.2.1 diff --git a/hw3/ws_example/server.py b/hw3/ws_example/server.py new file mode 100644 index 00000000..2bb5f1d1 --- /dev/null +++ b/hw3/ws_example/server.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass, field +from uuid import uuid4 + +from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect + +app = FastAPI() + + +@dataclass(slots=True) +class Broadcaster: + subscribers: list[WebSocket] = field(init=False, default_factory=list) + + async def subscribe(self, ws: WebSocket) -> None: + await ws.accept() + self.subscribers.append(ws) + + async def unsubscribe(self, ws: WebSocket) -> None: + self.subscribers.remove(ws) + + async def publish(self, message: str) -> None: + for ws in self.subscribers: + await ws.send_text(message) + + +broadcaster = Broadcaster() + + +@app.post("/publish") +async def post_publish(request: Request): + message = (await request.body()).decode() + await broadcaster.publish(message) + + +@app.websocket("/subscribe") +async def ws_subscribe(ws: WebSocket): + client_id = uuid4() + await broadcaster.subscribe(ws) + await broadcaster.publish(f"client {client_id} subscribed") + + try: + while True: + text = await ws.receive_text() + await broadcaster.publish(text) + except WebSocketDisconnect: + broadcaster.unsubscribe(ws) + await broadcaster.publish(f"client {client_id} unsubscribed") From 946ab01260ce36b38ef07de5f85ff5faa8da1b0b Mon Sep 17 00:00:00 2001 From: sidoine Date: Mon, 20 Oct 2025 07:29:30 +0300 Subject: [PATCH 04/48] HW4-Shop API with PostgreSQL and demonstration of transactions --- hw2/hw/shop_api/item/store/queries.py | 1 - .../Grafana-total-request.PNG | Bin hw3/hw/{GRAFANA-IMG => }/prometteuse.PNG | Bin .../request duration seocnd sum.PNG | Bin hw3/hw/shop Fast api.PNG | Bin 0 -> 40257 bytes hw4/grpc_example/README.md | 26 ++ hw4/grpc_example/__init__.py | 0 hw4/grpc_example/example_client.py | 25 ++ hw4/grpc_example/example_service.py | 25 ++ hw4/grpc_example/proto/ping.proto | 16 + hw4/grpc_example/requirements.txt | 1 + hw4/hw/Dockerfile | 33 ++ hw4/hw/README.md | 123 ++++++ hw4/hw/__init__.py | 0 hw4/hw/database.py | 24 ++ hw4/hw/docker-compose.yml | 34 ++ hw4/hw/init_db.py | 12 + hw4/hw/main.py | 13 + hw4/hw/python | 0 hw4/hw/report.txt | 58 +++ hw4/hw/requirements.txt | 7 + hw4/hw/shop_api/__init__.py | 0 hw4/hw/shop_api/cart/contracts.py | 19 + hw4/hw/shop_api/cart/routers.py | 101 +++++ hw4/hw/shop_api/cart/store/__init__.py | 13 + hw4/hw/shop_api/cart/store/models.py | 38 ++ hw4/hw/shop_api/cart/store/queries.py | 60 +++ hw4/hw/shop_api/item/contracts.py | 29 ++ hw4/hw/shop_api/item/routers.py | 127 +++++++ hw4/hw/shop_api/item/store/__init__.py | 14 + hw4/hw/shop_api/item/store/models.py | 27 ++ hw4/hw/shop_api/item/store/queries.py | 57 +++ hw4/hw/shop_api/uvicorn | 0 hw4/hw/test_homework2.py | 284 ++++++++++++++ hw4/hw/transaction_demo.py | 355 ++++++++++++++++++ hw4/rest_example/README.md | 3 + hw4/rest_example/__init__.py | 0 hw4/rest_example/api/__init__.py | 0 hw4/rest_example/api/pokemon/__init__.py | 9 + hw4/rest_example/api/pokemon/contracts.py | 41 ++ hw4/rest_example/api/pokemon/routes.py | 119 ++++++ hw4/rest_example/main.py | 7 + hw4/rest_example/requirements.txt | 1 + hw4/rest_example/store/__init__.py | 15 + hw4/rest_example/store/models.py | 19 + hw4/rest_example/store/queries.py | 75 ++++ hw4/ws_example/README.md | 19 + hw4/ws_example/__init__.py | 0 hw4/ws_example/client.py | 6 + hw4/ws_example/requirements.txt | 2 + hw4/ws_example/server.py | 46 +++ 51 files changed, 1883 insertions(+), 1 deletion(-) rename hw3/hw/{GRAFANA-IMG => }/Grafana-total-request.PNG (100%) rename hw3/hw/{GRAFANA-IMG => }/prometteuse.PNG (100%) rename hw3/hw/{GRAFANA-IMG => }/request duration seocnd sum.PNG (100%) create mode 100644 hw3/hw/shop Fast api.PNG create mode 100644 hw4/grpc_example/README.md create mode 100644 hw4/grpc_example/__init__.py create mode 100644 hw4/grpc_example/example_client.py create mode 100644 hw4/grpc_example/example_service.py create mode 100644 hw4/grpc_example/proto/ping.proto create mode 100644 hw4/grpc_example/requirements.txt create mode 100644 hw4/hw/Dockerfile create mode 100644 hw4/hw/README.md create mode 100644 hw4/hw/__init__.py create mode 100644 hw4/hw/database.py create mode 100644 hw4/hw/docker-compose.yml create mode 100644 hw4/hw/init_db.py create mode 100644 hw4/hw/main.py create mode 100644 hw4/hw/python create mode 100644 hw4/hw/report.txt create mode 100644 hw4/hw/requirements.txt create mode 100644 hw4/hw/shop_api/__init__.py create mode 100644 hw4/hw/shop_api/cart/contracts.py create mode 100644 hw4/hw/shop_api/cart/routers.py create mode 100644 hw4/hw/shop_api/cart/store/__init__.py create mode 100644 hw4/hw/shop_api/cart/store/models.py create mode 100644 hw4/hw/shop_api/cart/store/queries.py create mode 100644 hw4/hw/shop_api/item/contracts.py create mode 100644 hw4/hw/shop_api/item/routers.py create mode 100644 hw4/hw/shop_api/item/store/__init__.py create mode 100644 hw4/hw/shop_api/item/store/models.py create mode 100644 hw4/hw/shop_api/item/store/queries.py create mode 100644 hw4/hw/shop_api/uvicorn create mode 100644 hw4/hw/test_homework2.py create mode 100644 hw4/hw/transaction_demo.py create mode 100644 hw4/rest_example/README.md create mode 100644 hw4/rest_example/__init__.py create mode 100644 hw4/rest_example/api/__init__.py create mode 100644 hw4/rest_example/api/pokemon/__init__.py create mode 100644 hw4/rest_example/api/pokemon/contracts.py create mode 100644 hw4/rest_example/api/pokemon/routes.py create mode 100644 hw4/rest_example/main.py create mode 100644 hw4/rest_example/requirements.txt create mode 100644 hw4/rest_example/store/__init__.py create mode 100644 hw4/rest_example/store/models.py create mode 100644 hw4/rest_example/store/queries.py create mode 100644 hw4/ws_example/README.md create mode 100644 hw4/ws_example/__init__.py create mode 100644 hw4/ws_example/client.py create mode 100644 hw4/ws_example/requirements.txt create mode 100644 hw4/ws_example/server.py diff --git a/hw2/hw/shop_api/item/store/queries.py b/hw2/hw/shop_api/item/store/queries.py index eff87679..a8d506c8 100644 --- a/hw2/hw/shop_api/item/store/queries.py +++ b/hw2/hw/shop_api/item/store/queries.py @@ -12,7 +12,6 @@ def int_id_generator() -> Iterable[int]: yield i i += 1 - _id_generator = int_id_generator() diff --git a/hw3/hw/GRAFANA-IMG/Grafana-total-request.PNG b/hw3/hw/Grafana-total-request.PNG similarity index 100% rename from hw3/hw/GRAFANA-IMG/Grafana-total-request.PNG rename to hw3/hw/Grafana-total-request.PNG diff --git a/hw3/hw/GRAFANA-IMG/prometteuse.PNG b/hw3/hw/prometteuse.PNG similarity index 100% rename from hw3/hw/GRAFANA-IMG/prometteuse.PNG rename to hw3/hw/prometteuse.PNG diff --git a/hw3/hw/GRAFANA-IMG/request duration seocnd sum.PNG b/hw3/hw/request duration seocnd sum.PNG similarity index 100% rename from hw3/hw/GRAFANA-IMG/request duration seocnd sum.PNG rename to hw3/hw/request duration seocnd sum.PNG diff --git a/hw3/hw/shop Fast api.PNG b/hw3/hw/shop Fast api.PNG new file mode 100644 index 0000000000000000000000000000000000000000..7ed412d7f3086ae970b4ab01c4c3086042f7dc79 GIT binary patch literal 40257 zcmeFZ2~?BU+BR%YPwS~xu@yvR^3-Dy5TY{4m|BadOezWjLaG!I8DdNzOi64#RD}X6 zlQKnwkWvK7AOr}BQ;32PO)Mc~Dv1z7ATb08Wc;55JDm6Qf8YOmhwuB=zfRV23E>&` z-p_vaecji6UHj_cVE;9%Hm_Q-V#S(+2fjGEV#RyX6)XPq(_h{PzUi!h*#iH)13&8j z`HJRYOEK`vpX2ui?Om~=wQ#lO)O*11ADllB0bj9V?IXj#cc{?szgn@Pa^&C_dyl2Z zOluR1qvNVTDm~<#wV+3X5=L&COv+&EWQr7QUpsBnR_^o{teRaKWov79Hbf{Q5bNe}`cH(XXF| zBc{qve*3uQ3Sxdcvu^(pPTsF)UU|7aH|y6EKf1md*GbWij&1~oW5o)WDB**v0&ByM z7b7h?woBhJ{P_L7y?Ot)eEH!QFnwM?S^ayfE1a}(;>rkirzc7&jK@>*6mT-wQHLqT zV>P6&q{Ojnq^z-*lBk85HQV6*^yiP<>*0F&1Ntsw?>r5?6A4OIGA3jWmCi^GxsEz8 zNo(Act6sb~9*cMFH+gw^gW=E>D_`99gMrN(n{RsZAGR}GLx|;8HW@fMd1bgvfR53P z8ISsT%VxtBzmyDNf#VvOxcEbpmfpEphClv%fAq&w`!dyRe)U!C;N-DW&o3;RDzjn^ z6>M}7S8}HpyJkKR55dF1%n%o{zRLq8y#)Yy$q=vgr2PWb^-CMszc6> z52=!MSTOnvP64%o-$)3{DN#f3oAPj04Twh_0Zb;MxiYn3BugC9LDf+IVkFE3A!?*|k#FqSvp zjKpm%^xn-)Wk%mx^eCX5V8yPj-#d8f2nU=-dAe<4&Lx+ktzIK8KivSXf~N#U#iAh; zZGhNc^oklBY_4HXx_n%Snh+y`L7~0T1+l5yWh)&o zqhb01AMrN=?u>2pbt$@YXm+9fyv%dVg>TOxA9GRt>HQPWtJ ziWb6l^q&rXHe(4s@xI~K!MkXVIue5HL#ikse(B7W729mN@&4jiD z(2Aegfm+zBWt+?n$p_Khh-R^Wu7qYvUUb6~RvKFQ!;u{`Ht5B1x`*Ep9+mUM zfEsw9$2lRPce;i={zB0{|3N}$*_v8M9aE?_;x?N(5`? zJq8)!(CnQ}y&2M|32#LZ#W-?5>YaLC-T~p27>&gV`@CnLY!wed(Qwrq9vtoI z3jxNDWv!fnd{S{Mtxz4#2=R^CqmyInYXri~*T>tsPzTPBrHXG|e;6xsj7cmM=x#2w zwzihhgW@L&E9#cJ{{EBO6VE|XNVjeD`M{b=-%}Z?7ws))Vz)+x;brK#R}HNNvzfpv zdDbFof!4?vxqBAlnyme)i{3Vp5fThp{OKipVTW|J-x5YI!beeit(iwSh1#TyQ%SRt zq$R{=mUBk-E8N`AU~}CMTb|t`=uZeAUA>Fzgv=^JHqIePA7_LVp1#xiB(I7t4aM_!!}qpm zzEDZlmlsxbyg|j|+gpgX$vdP+_%GWDCKc#gqzl8B=>lRMcwwJ-dO}}n%^pGf>jan+ zTphs&l-l>L(6_^zJNG7F2KkD1hX9Hy6Z#_jdeq$lOG6_rsy266?q8lgD=ydm-xG5F zgD4n~oTtX>1;$9Z;%sOCrJ}J7zYoTjHvVJaJ}MEQPoMsz9m@ALbbH172Y4nXr_V)j zL~+{1#qAS_k1|va~|>V#TtKq%V_!S5~cA3^jBZ~ttle`rJ4%`2U1&f_&J_`^Pde@ z|8m6_wwB!Uh{rF?M8LXJ7e+y4pZ*x5T8F*!*y3a8a6ZVl*iFX09%F4`y#DWw_m8*a z%5QU0A-w`$Uyn`J*2BI*SKaHUgNzgE=Qrk7Fa+hrCV%8zLdzC8eytq$ZY$ zF(O`kVD`%a-rLy|FPjO4&<_hhHdj#J4e+)bd;Y%vZx_gb+3>CJF2C|ydIYb(GgF%3 z_yS$-8e~1Lv1NF2XZwl96YXvHjNSUa{q-K;fO@RJch&3Dc0u}LrQwSAYd-A>qM&lb zcmfiyl@z1Wi*6AFWzGF2*Cl7fugpR&(1 zoPUHvBe%I4@Z*Z_8(+HuciImF>jy?m4d2{uzQa7m3ExQ3YJR!UtxDvJ_ry)mtV9(h z)}*9B*>-OJ5-S!g7v~+KW?lW=T@sK~Tma*noGRZ+(o)ir=H0}&o*!p2$k(LXtBlLd zN~Uv^I}WT8V#;`$Qg?ib2_NE#4}6wS)rfobsI-DMkG?Nj44pY{rsQwrv5DUdCxw7# zD#_v{7rJF~98EX_udndZWvlilW*?7q=6T1I-w^q(7#QgQd(oTB_g~LOtA zavNM8&BLcEL(9MkXC~2o5ipE+pfgrN(vE9EFR*>SRdmU_7n8CRJSW(pEl_ebvR-6( z{1+oKr*cGXv$}j}W-Md^_3Uc5{APL*tMb-z+e*xoUJEWQw|C_HNjot!6dNbrF=i7& zbN3&k+siQ3)>%y%cU?ftMgm4KhWMQG4tl&QA%L~G_xbi#cHT_F40XQLOu1C`#+(=B z?H1r{DxRRJ5vs`q6lpSe8E{SRZ6%T=I&9hS2fI4SIMfy6AphVbMJAJ-wM!vR2|dnD z)_JvA^w^5iykj5neW9siH*IWUTvK_(S2hvcT~KIbQI#wp;YBo;c29LxGdkNrIG?}r z5UYGaSdH0Z=XfGLvuF0|to+g`H;Ptw5{jXq)TM68Il4Z`Su5<=Y5X4Y-9VZ0(%1X1 zgHv@4y>eksNcY@oRa&c+%PEo>NQyj2p3v8|m}} zCO&mYPE3MEWFJ*E7DBLS50}(}{>trTkVDFoOXD3oE0CEzZ#vho(!=V^X-5u63Y0xO z-(oZ6Ow z!c4A&UMN!?l%#7csi${Ln%X#XS7aX`;%d7Yeq>5)SK`FJYN0 z88J}o0Ji1uMWmU4@~6Ixcofr0U9|Qy7{6J5GMR#sXP6n+hG*?WB3STbYx_gr$F!9{ z{4(@2x$4$=aU&At+lSwY*EVZT#t_YwIA~H1Jd5ehnv9w-{fR&*yr3iC5*}P-)?fDQ zukP-S=+GBJ>$2Qt&RSFqmqMbsqgkX54%n{B9LbVpc%od{YtKaV%*q?6lZt@>RjDEjjy*osqB_)5MhG{C>hsr{xNnYSs$clyw-$QZ zTfrWGB({=ydp5Bbd+FWqkNWBbq=(0%IbE`7a6`lxwNja+MY-AbsVbVOWF$i|lnm=3 zA5N3^EiJ9e8YdqeT@4Ne*3nxQ789C`uD+M9Y1DX&_hvJYfsiq1hIDu?2ku+B!EtzS zvc65A6FSiQAI5skhjM@kfF3%*0YW( zN(sHh)+uOpLGNyEJ3cF$J8zS0DrInC~`BGbm!tkXH7LlTZ%o&**-w-Y({7% zYRtsM?US~mj;8@j;h$2ys2AXNQiaK?Rk?+?rnz*P@n8oA#ioeQw}dB zdv>xC*f-*j$~i^M6T}gzL@HV`h7X=GeB8`5R~AVj)jG}9d2Wv8$<&+(9Kq{h`zn4G zF(8N`Zc!Lm&gyvm;2|;4Do<@rM-E4hslFMV^(b;{n&z#-A0BAwUJ}fOQ{4(c*?fOS zj(bd%u0A%HOhHYfFpsDqA$hB8HU#?A(6+BMSW`&{L*q3#nuvFd$kWW@E z349l8&f`(C?l>IkZ1(k;IpNA+R(WaW;#9|1!HvTT z+s`1M=>08jyS+sSU%WFjtkzK??PiSa-**I23ABR&T~3wme|eY_UP&a_Y~Fly87p5) z1z?SX)Zx$$)$7-1$GYM1p6GcnZYT>`Ogdt0lcny?)`I3ep2F7!z&*YKuwH(CK5l$` zd`^r<-%QDt*U;Tp8;-pA2x5R<1}FtXJh^mj%uFd13KNu4Zo*K2q&?)QARi_&W>fcn zz7o8rXZ-b*T8Dz+Jw34$l;2fw>Xne-{rmT~x&O@oy=)hD83z)8URCE2k6wJ_A8uJC zznxUHz9AZBgT47`0+pTHmdI>Q(%)H}{dyhW`a?q-?vLBbzOG$gkg~^9KaG~LF#mJUzJvd1;`9vy2n0=tWQ*Zua?UF?YHd-!EI>QBC0gylI$G7a>>)cxBFiv*hC5CmD z8c$8#V1)-F4Di1BzZ1O!C&tbi$-+G^zxV}0OFOW=fAF4>PJH2<^`CgN|J@~GXORM= zIhHAj;+IM>uiOEWfunpgHr;BDp1MJo$NV0N5BOvZc_ch|hzl*{R#xx0mc3 zgs+Wfc??X>eQaFEUbYhSGba>+RS`eA+2S0BgmD@3%KQv>9Vz3>@+8&?Z;{3>ilO@s zXJ-DzgY3OMDWR-I4ZCw;0r8X-U)TOA=ynp3tBaOJmpzUp7fZ{>M-gpwa`;CWWm#Jr z=|p|J2%DIJMg8S`aL=UpM(evrI7)A@;dxvV+zJB4O=vCnbM>SF_09Eg;V$`Tn}_pq zR&nk)rzJ@I(`0JNL|3AsTPV(%Y;7aDU|Y~s1(MNa z!xMQv00CDb=!wr`(u&Q4WUY zCH1m)ubQ@OGFdm)(%AZG4&3ru(}mM9=Y8S)Z9I2EO-1==t7pne`3e<y8s1fgGuzh=lH&V#q+(NLGO{M*TufFPGYb|Imf}7z2h7K=#A2BN zPPhDN?Fkqk#;Y{0C>KA@$iTe1?tdl+bp>m3?3ekWGF1xmS7+i5--RYw+Skjs-2>43 z)eb3hh-z+ml!7W&1eDglXB&gH7H^EwYtOvUJ%Hj%X!-Z@gW}m*2z1&95-G)24j1-L z`@7*V5gYWrj?wqhrdpSX!0Upe&M!1Jnu4qnO?geb$+#ma${kX*;{&$u&ONqOk&B<5 z0uaF>vYkkqpw$>Dg)cWt&|P+Y!q>?9+Jd*B`Zbc|;M08ym`mNu$)jOvYzo*4+gq!q z=P$JqpFy|rH{+Ss$|!~%_^dDK=fn0=CZ^@cFcvd-fH@jn<~qPtV0NPt$TO_t;=Fj^ zsXICqKd8A&0PCAOy5Ecj1AIeYa~Cw?R7}iK^VN#QjGjbs1gDJq6F-#*4_1>!EdA8@jd3JJbWIr^!M+)xW~L1oOd65W6BSTle0*DvEZTK-H=c zKV1dbgl*yYlC4}}NDMNi_B>WvpuWD6?$^rHv|>XXPKT(&_tVGBu-JtHHGP(_1&e%W z&a6+~CUlOLC4EInr>40znQWYWNQ@n<=ufwan)v%9caE~VZ*h@$7>a4maFX^8e^i*L zi!#Ec&-|7bfH6}gINPym_BpRVTFHnL@et?Sx|G(+CIR{9!*zC--cy4+G`IXHwP(7@ZbZ}&aZFR5zkY&!Qj62`Sxcu^~mjplh;rzVe} zDnE>T36ybLlA`YfVzA0qlx99>#pb_i6G;d{lF&7z3EE8lalbq^Z-jc<|1O5=E!Cy6lhO$Hh}X`XX-)erR3{ra z2rxzKZsQ2T-HNyy{VQTeR2YzQ2+Zcr#WWF0f4Zr7!4Q=0*KGF;Ht=j+%Y@yRJGV<6 zf@3%)Yu4B;7ZjhorSP_GKp_w=Gn{VcD)itNV?B+U{9iEaFYKU!K3uuA6(TJ}A$n$}}yXEh$y4^rZ zK7IP+$$^D$ALH%hlaGD(8(O~tfbYw2*nozti<2*W0svwFx)F(mAxF|?#!6-&rV!tC z0`?^%DY!@T%I7WTT3NPm=|UF@Xa?$UAek`4Mi6urWOW7lp`jkV;#Xj95}9Dr5C_BB z^1dTJHn#4|^G1|9@_Q8g1SmTGZ$Zre2-9&R6z${3U!38SP=-dY`0yIh#`^KgHUe!> zC6~)x+}uj{`};F^eObodZ(Q+qTw??#N6&?GL^u@Dt`{i_?oFFH|6 z%!(@WS#k^pX0;grBLb|;C0P$kM; zwv+v_g4Q_rnYY=(4+KaBy>UolKL}+`!l{ha00o|r$|fc8^4Xu3`wu7{e)|wks|6PR z!5#_YgCn0qempn%Nmli%xO52e2|&F~awU1befCWlTInd)%bxr_w6NR-o7#H6k4*$^ooe-{kl$5KkX0mrk zMJMZ#<;?^W_0maJqErz)myO!pL^bWYZ|5M(Dx3D3iQUA3xgV)6BF|I&<>*^dr?>Rpdk;H9_b*()c|gZ5!>l?o^OJjNkcw{w^brdS8<< z)otE>TK7G(&xxB!?Q0Us;fBN|H2c}fWxnm6oz&e$b_D~2MvizaTpFMII`Q&qY8JG_ z`5tb#cwZ9%^Qtkv4xr702W7I277-PtHvA*^(~5tL_DgR*yk7(#Ry}!d242v;qiJJ# z{p)0V2Lyoj*Rh$|p3;7-CFaMh>`4LIU!`N^$nUfX&@)}_j#;Ops)x2(#iGtSJlTgv zVyN)65;lu?CIPi2SM4@*l-NW7ZFtqrx5u|implaWC08?y^kobOS=A-!F)W6Ap3lze zo@_0Awo1JYZ1X+cceyX~c2cT^`P`wJ6a3LV#{z``=d#R{>l3&w6@{QZb-C*8qOu)9 ztAmNYKJ;CkI`z;U?InJ&1J)uDVB0&@WZp8{?onlfWECbI(%;}Zq9S|F&Ag0NWK19- z?(+oRX*=@kX&XF{cFavizlx@y+OI%Vl!^>50PAhTYRKl z^`YqJ+1U?jj&WM_F}0Ooed+b))?TMX({7ReD-|8hZf~TU(|m0mBP+{DL2(rA5)wC* zLrt5)6M$rLWgP+J#RCKZtm0;m-frA*9czPfseACyo6`;aR4VF7T7g*h0|-QC7b1rf z)JkttCCkGW!j3L#fvH^K#`N;D=r1z#E1?+wPti^l&dX$MAdpbnOa~Pfx`FKVACFaZ zx(_aDR-g0AQ$KUj0a_`=~@?Fj` z(B_Ctr1``APYCHcyP-d&_n@@=_BCzo_caY!yRHx?Heaw z{VU0LUgJ?MU5x}4y}pgK$Pvg*a=f^rEN6J0+v`CM{JSr1?DWGYhV{AEVX|(ytmS z0^T|f;)Rk_?MvT<=L>v0tCn<2@pXPVCadeM8KU`bwRTe5>m~wl-<9V=b&G+9X%s zFIrO!YsA|n!9i*Qj!PU%-qA$ZBHc~i65Z7RloQTm^i`lMeF-}!-Jhl7^_GF;;Efh`d89q z)X@SG+W#X-Q>22fGM1DIAleXdU*X9Q3@^;DC5>8#;X(D@gG~V0_4sePxxf1QU3LE( zuhIgNy*-)NyS2~T&6q>~?s!9KNw~LLLIM)l>}WA43CK6~_Ga{y4}}V91FJ zq$gmIy@C)cLBPt>Kr!iWl&a+%?2^MT2c#eF95j@j_!}*OR9U^Tq;I)2_F=(q7nlVK z6Fw)F%WR(+q&|kC8kwRsvycV`ghQ%^PMv8DYdsT}XzbSE_!qEcA_8UloN}~WXD2hH za~Jmo{lY@Xg4Nx5_kI!G^yH=()H?vBk>}^q{#!cryEVbs{r_a$(b+?GXEi;Fzc4bF zN42)MlEwWrwX&Ux7BI{KfzL9|UoKs&y_4VA5+bUa({+3~PDc!MVV$a+4~(GHk64jA z45e9f^!!nA<-U>n67j4_Rq=*k66w9N<{en)&(>W*L%=ZT#=|iQFiN*`W9JNsn-F+aT`NR;uh;(7wV%k6;Zs(FyCy9xN`R{cWVV+ zrUOF!uN8}9$2pkTbf{vLXSNU2`Z1TpUQq-6B7P|qQ|#m`nnO&^YfY1Vnw2G9j120_8Up*eb;RHFXT<7`OJN?9n@R(uxVqE^~a8SKjos;sXqF3+xTMffb9U);;|HRDr1W! zL4Xd+G<$(+9(qTO+Ov+LZD|h7`;;28IMyR=om^5%TI|nc>O3X)NGk>8OVgTsdN1+u zFoyc*G9#y~lV+E42|kX4`2Z=ZLJxT|x9Bs~`xT8Zs~?dCEAGXN5+_%aBX2Tk8Nl@Z zdFOgR%?nv(e5TeS2AiT^;x5avhjqmZe_hsZ5D9#_;-Oqqu;?Rh%`ehB0Xpl}seqge zNFdd_)pus#{nbb=G`%@v2TlY@86I4N-g#`c7I7xY5k3+m zzBSxhlPdmp!X_x7AbGt~* zw>ul&vH`6p`5nsPT2`%`K@@IgooS&H;*e71C52?g2smlH2-w2(8-M!hwQsQ?h z&D^9GZP}EI~WFTDv9)28E-Rq1RuQUls-g?8KIJ)U`tSJC7>0?0e0Y%=kj#>r)vn~ut#e*%%b;~-U^;E%|S1C}1m zEwn40=Tb5k%JU>gi6hf0e1=ImFhLvP#ip`os+SE!(`78>X6F94N%=zCO8R>w;BqrG zgJ+QP37r(NppdTpFwb9weCT??PM{QNW`c7xlp>*@b`ErRw4#P6jhf=L+-ZNtzpZadRdPm>hIumS}c$b^h+$AbKaLX!T z$lXHZHKFQPZY!3w6ZE5nJ z3_Y6i)QcBbLqomN0EHT%?t>%^VQB3Zk&s|^wNJu)I|bCWW`2jXOHzP7eg(U@WK zV53fLZO1aZ%cE%P)Ma=u<}?cVS8$srAm%RZl6KO(7nkPDqv6WBpF*+$_4AKAL7`NE zE@!T)y|k0Gr89fDF36#`E8ng-J~4%~NBdK2zHl{P`2sPXAC4!BZx)#+#7&&5@Cm_N z_@Xl-k8MxG8<`LwfXAcLQau$fi|7w0;fRbrzfiL=x zhvP$MB|dV&+|W-Bn~_Ph9|*EoR9;J*{Gj*}w%ZOh9xEq?wj>O$cgHD$lZHJO9*JkM zT;`(hrH5nl@V+L;EcEV25PQoj0`xQvK`XII)?0y^xzEezs%VJsLI$vk+h)olI1rml zwacEAwe`z`@`FI<)9702uZM>sYkGFPBw=hOcyC2@mP z6mXNIINT9K{-Df_p?vGb9yqJ|qw3t{?YFrZeDVngoUw*@etT|#G5vI0E_iSJ5U2_k znJa;6=(0g!MH?UxHOe^sY^eCF$kZn7T++M%fILSPrJ0fMj2dM1+DSWLpCS9J<=Wm z@HyPG|6yr~c%TsD;uuM4%Sz$_;C|Cr6fJIs*&}7!RC_bc=~g+h?F-ZM;^GAGinAbT zyU1%s)zO@26!9JtHJDcq8q3!Ce^9nQ7RHf{GKk-_nXZ~E>vAtF!L^^oc>C}z69O8E z`i#u!dy}nn;z^*=c{P#o0Zg=%gRFO~igh+qD$zACW~Top-N09r{c2kp za=ecc+rI4N{duRMF5ROS{iBrhhdgyOTNa4X6$O+_0~@J=*VS{Q^*7y*KklMMRk=-4 zFNX(Jxr3Jc!;sI|6(e7X)JG!`;YxTD`V(-cx1-ZZ;nzny>$L=tP4J=B8>*|Dv+raR z@vCyx+HRgMSKUumG$F=TX*Kg_X77*y#}*NAj8mxQ1L{u4>_}!ASgLAJP-ELnK3Jts z@iBVPr^)}@{aWe76Kmtgx^5&SfkG`oXC3+>hsc@*inyg9xxmpEAjOsj=AKper-z-u zW@hwgVh7vg+XlnyFzO_sD95I!p|`kN3K&roVIEfM7x8{st+zp@ie07VA~lSPQj_!G z7T}QRD8;+qbcz6Lqfz2=@nbSd$9k(R1(PJU|6FoD4j7>}v9FO9etuP1mC;RZQ(Kzw z7{pl8k9|Rs6T28QQ4pCxSJP5ey^7lJ|-KWO;EC65LJKJo-QP+D4BFT_(*=9+Q$Sp zSQrJk;nI^czfbZmg1+lKRMs^aMJ_I3wQz0?=k*E;J-{dt%Zk&u#dgazcmL)q>nuA&wOFVHD4#^~_a`{+=oAix zzn!e`QsvV^dB+fY5ChC~?IDLO*{sGkWA44)zG zbyZ&HIcrcD$mId_pnKH z=AJmxA|R>xEY~r6gC^+WJIlo7%N{}@sEo@YN=Po`kS@NOGNMs>_twl$bj+{-^HY@O z0{bvcfF2BS8qSrkCjm|@Z_w~yhZ&`@oPs*ixRHMya3LP#3UfTP8Nj~Ew-KRU{ zw~s9u0)KyYYR6B#lji-vh#;rT!5=2v-RA^uHp&%$ezROReA!FDBJ{I!qL8~Xr&s=A zc(DHI>cAG3aE_}kD zafg!QK{=gG`xYT75#h<6ojYT0G9WOpVp+p^x%$5d;s1?f^oLyaUSP%JrqxA(zU$@C zLints|Fe?OvSw0G)0KM6A8AH`T27Kf?H{7){|3>h6CUNsDTRV9_ zL1B!EU!MO}glMp70gjnF2Fs1D?bfYZSr#7y!orI6>W#m^@ZeuPT0jBp=~aWPJtS~; z*4NjM1^9_N0ss5bXIG#mZ{?sx%KN{FC&x?%xC__E%#_ywl_?l?#eEwIv4ynD=t40h z0!B-}_gNgO4sbG6r#sJnY$$O9RO@x+qf0j<&HrJ)fepWxk^VPs6!h=vQHy~_06N`U zDD{GIGQQ^0yRwN0C_8}@fJY#f4{a3>D)aFS=&b{I?K(0jRO%R}X5$`?7OCh8`e#borFfN22X!MvcY)`&<6OS@(aw{ON8}bs(M- zsY`x@$h(*8>JSxR?38ETazbe*t-a_}N}mhKFrpF5b9dgF90AW7E&30#R>+P!xB7mw zJ^shr75uYG^q==VxcPL+9Btn>-vkX%f@doLuaz__J$V6e6Q>>&r-OcP+?=Q;PF5O9 zpMdr~IHdGhMIT^4C=&I)mbSHK%+3voU*?GF6055xjFa7^iQndrrvwhk!4xaf|&wqlWPrI7|%ei~5qKIBPDQ_dH)cr&Y4BvZaP-oJPYPhbrDFM?@ z&s}}zKFOdLiJcbP;7y#WExpF45X{5W6QgsV^-O8tj5~lp#>Wks2~f$>p1)bEaR}yz z)cT?l9b4R(&!{q+HBHpd9xI_ufW>l!`WwF7){tJ&|2PdEX zZJ8}T4kNqa!Yi1^OZm-1q5`~mIdx!msuRT`T66`u6c&qe*5rt){Q*5oSt=91UeK&q z9fnIbLS-bT0Y+6l*n!H5mIby+939%!Q+8kwvQXG~6xyHr8~VmRbdX~Iy&?AI1CgOiTErxGQjsZpEBC$~nWbrw`1 zLY1!1E*r{w12k+U0MfQb-)sIA9kWbdCbPw_CbD#ydDJn}Ts6HrU#?kvx$?aVy6a#m zpaAa9IOlC$R5F``>GBB=4%ZpQ$V?ncTGxAlA7gv zHpK3V$NUP{0QsBO3j!OBfc8jxdsoNyG7gf*D)x^Rh$!0hNLD#B3avz;-E|L+847cV zEKkWzz-Vs}Yvxh&a$xl+i(8#-U7=A+UN@`>lWhn=>rgA8*fc>)zwSWS?W#5d9JjuX zU}Z;A_EVAc5SVxusB$qdoakdA$UfB5L3z-&SgSTVYRc7owZ5AVI5 zNuk}Y&AaHEr=E_Clm!WwW&sDCAt+qDgRjoo^>!zNCM1Xb&-JYe0eYXJV9{JzA6FF% z-WY5{NF$slD#JWNqox8qUgoO(Ntycri`%OXjcyqhUr4PaXAR#Nv>xj0e!5C{L;fv; zemoIh?d-)zFxh1{1f=xv?6WahC&SsqB%jTRJ4FK0`GRcSO9jA5ipXpl`wSp_bx|y< zWDx@ZItKRN`ZwqpPq2ZWeD08Q8Wk%%yel8nFazuoRhLRm0qje_9P?~-n zqIx^bhaLMm2Q~r1=|TV#cW;SS&Yeh0V)sZYZ2sah%-k*w2{z|?+_3KM0U&YlPl*)( zBsTlW0EzonO*OP?q{`r&KV<_FInm`mcRz#=$HQ#&DaFbqc!<^Vh6abvaAj^dq@{Kk z@$!g2Zfn7wPpq`VNSv*ElHIS+7*HL$#wPsD=_~@$vkP2Ww$$`0N;o$rL??PqtTdR$ z4pFo!S@bnCf{4Ex5U&4dLqUaY7Hxy!Ps8|&-Rb`6F+0ukrgY87_gV9$WgN<^C%W2x zh&|LzppC?qr+Oa%FylgI1+#wH59WDiaUpH*l_H5ahZqNv>%wh;@hw=u9lQG7bv}9uD&7Md9W{Pw)$%q9E2I8D z%M|_QjWR`^eOo|B3DqH>6O&+eKUUm47%a60Jp&v$SaD{&qyByku6Hp7a_M1u*tgWy zg;{b*N`&a?E;GWs> zx_#PQn-*u^6F-h!ZNTVeN@objaEJ4+m)Cc|x*8u7fIk_&ZERZ4B%y`y8x zhLAhUdl>QGTC*SACbaCyfPr0H-tK(1Me^3HePZs#ix(Ym(J(^gxIRJBJo_K<$E0k2 zde2P5?0>WgfdO3o3kxrAocV7z&HNV@{@)=5{Qnsi7H=8TJQ)&eig?%!J5Cxm8W2}% z<*!^tUx{MUHQnMoWnuB~t&(1HaTRo+j2<1QIFlUObB3k4`(V!cfcd??()s}F?YF#& z`n#QadXw%h`R?fT@QK)MNGR`5-~Ate&R_QZ2he#4ceAR|n(5VHUxf*5-%))mEE2rb z2WLEd+PV#!<&rGfC^3TkJ>fN5h5@J2E^II#tJx;bJc->b^0bXsAY`$Ofo5n=w-vxr zb+0@W!Q$<4Q=6=NYo%iRw_B-x=zUV%K%mJsU>R0F!Cy*%u;2cg(MVST1bX>G7lHQO z^Z^Ga509*7aqh$>R~lEl7{Rf_957YdaZ{h3MEk#OSro9vn7YiQxnnl6LTy z&Z!!q$tjtx$tl*skCyJeqG%Jv)gweZ8^ZX#Bwm|EXJc?&#`9QktFwmGP7BV zfRvpQ_PvO@T6U`TEZHL~JJ!2XW>+2CGm||qu<)=o$tSNc`DEal;x|B*eSge8h3aFFEQur=jVw%|Vls z@CZ-&=;WxNh_Mo^{45eo-$+{}2nAzHv00IBq*rhx&L(DLF=Jc|mO91ZdwC}dclSxk z2Yr(^vdY7B0UXitd>&$cmy0 zMTXo2zj*UEzd!UW?<2i{*Krp;3LCw=xb#`cFS1AguJH)B!&gyM{rxqk zH$#rH8wuD05;PaE82$A)r|@pj;4hx4qi)qtWw)y*n_o{xi*OT(I=@!x$!pD7nLE5X zlezOl7kU6H9EQ2j^LqID+>@X3y=H1FquD!H>{UNoGoyA)m{)0q9i8FVeYvjgd5iA_ zI0f*bG0?_9kmD71oXlN+bW5>0iy=Ag9g^=FAe)2-CTw^EcakcU_d>@}c6VtyK;w{$ zalIBz1yY-#WV`CdSHv8X&0{u3%jnl_Qe*|)s{kGEIrWxFFF=cW=|vWu#luy3zd8WR zTU|ajcj*CfZ`uqFYQkolV3{*8vTN1srAguHwKxG#-x zjg>g@jgoD`ZgH-%4r$;2f_4@fZYvD zW|BFn;OI1I5dI82O9tcH>sGHV5un+*gKdJjzqd4lte|F%+r|#Dh|^Bz%sLE7$Ywtk z0fOkD!`bEGn*`<)83heHFd6g-yToP*;=K$6}2MJK}7|n$|N$R)hZQ)6i{SNl>$=0h(W@X z#MTNG3RDFmW2^`i5s*Qq5Mo7$f`BGUfP|zZK#V|Q$VBGztOTvy?H=B}&v`$*@4hZS zaCxm{t*rH|=eh6azW=|!)xfs+E+WwhozBtbi)3Q&-FBHOGk%9HDCJT@3jE*9hc@lm zwio@e3TE^1_@mxGJ|Exl`y>U~uLa_(mReQ(Zr$B!nIom_QUP0WT!~L&rmdDQ-QuC( zYXOKJJoZlvFuupv+lD#|yxUvaqA(+@PMTY*bLnFT){lpu!vhn$hht}#I(81*JFr2D z^7t$Q?zwuqSC$*OB@L*XP^5vKnEM-zA0(@ax}a4F#va)ZRAELUxAtIYCj#uS=z(a` zzblWDureb~RI*#rv3Aeh@W zZ#I_=_)>|2b3zqEa?U8jgD3eA2Csr^JJYlw zInh0Swx_w;$+rmOOl>6&K00{RdXjG2h{ntWL{2`LO-h;Tk#SW&#(Jr2FP%WbqDV5Z)|6|dG{a6Qkze#A{dk6!{_2|i~s07JEv ztz$HwJQaJNt=mc(*)bf^oz2e!+0la&R?J)lF@fu8mdp2xKA;ubc$dj%CABNgh0~t4 z{y<0t#ut+N_vuMjV4p!l?n+r%N<7M+MHYD>pVf5cIsuv2JGtA6Z7W$W2!W+MJZ+Kl>K3v&*@$I^-iKWeGp95|*wcs?C5y$Y3uWfMsoO;2UBJaCy@G7b-6_=i zV)SsU;9ItB$$m}-X74%YDO`azc&1^<-VdPmJP^s(4W$b}p6D!F>hAzQ!pzX5)#;mjn(PPDc8p(uIlzX4RQn&5Sq#8WurMNi+a zWsD#6FU+ZjKwy4VjpoxqUbIRVM!=S8R(@Y$Z=-Dqd`2GLDrHQe*+LI=OOA=T?v5FM zVOT_{(8=acdZd6!aYcGX^$zch(!?l3MM+@+t#-?}X`QvV=N|iWj4M=i zB)ih2SX3+^%ALi5kwAO|8h&QWI$SVUm+z>;>|B?8K@g@i;g|d5$~kRFx@>^`6fo#wq@Q=F2AzS=0X_`1z$-KQ^{RWl69~(oyIO?ZKir=srLA=wU={}FOBsp66eA} zwXY=VnayjPB8EoQq^Fyu&bsj#Cw?`5cZJt7`g?5^GAnR0PqFum;O#VD zD91|s029^yS>kL@RTs_BZv>Jo<)rIg{zSclpgH$cl2_B?6IKnWA;^aRL?jH7EA0Wk zf?wAjc-Ll^fSM34P6@X7BClX@hL$}*UiDvb6^Rzgtxe*Xfs#`1LV$f6Ryl{z$pxi?NyW+fILAvRY17X)PP)rJW{wE4V9EK1}rZ zEyN*ffho+fPrm=p%!PImBzaM&p+eSdQauNofKd)R4QxBT$!vks6|O1{3!XXz`qM7x zH^6_nX)>j;=;Y27F0{uEhC~?VaS5jj1y_Wunj!6!9ixPU&cf=s)0rDJDhJYK2m72# zNf6hC76zY6Wy12iPU(A|e+iC2`_ybOKY$JaqqnLUK#E~BX^UO|TuaOY zJJ0881;cLn^hhQT6T!U%hJ7@l28PKW9pK$NSQi-lCqnM&RdWjU{5NE|I5EebMW{wi z2|f2ttki)W5-}S?@`v8%H@t@db-u>aHB$vtlnu?14ANZqkn0nswh~1wndfLR#n=KZ z<3n8mw!_l2#ACKXcP~?dA4rDeC>6qzTXx%aO;ZyXb&aCIZ(2fAeO&Vdnm%^rzO3b_ zWgA7ZM_&Av`m_(gTz0hcqbeY!n4XC%Y&j?d)h-bAVc;YH?;r>x=nLDW)Te^qReuN zL(p0u#qn7-b}f$#y>hpiFGC0H5Basxtrrp)=i>1SODa0d+O)nnFie=GE7FrF4yq!q ztB#J%9fR)XnZWz{Yc|xE%swR(dDkUB%k5i;g`j(FLZx-U2+F)F^mZ?23Jt^hH^^K4 z!^nD-f&JGG@&t^&^TooLzoK4%BF9_#k0dt3buaBKI>7CO>@bXdJ|>F0a1H(*2D_@y zR~ab3!3M0^M{8HfO)PB$8R_YUy-yI48bD`nXSo5Ia-x7)$RaE7P~?pNdl2k|(Ux0$ zqwXPmS0HqQAl%*yT6Xzf^KYRJP74b-gpebfj}>7qzK#6eSugcp0t`VGtxdqmADKb8 z0Cn|V04mtI^CIbeQM5;=Q$y~b^lRRGtVy;(!YSj1{VPX&2OWvNPU;slBFB;cy4QfxDir@ zKs0iwWQZ1q>3{tj9~`0Qqb-nqf^j0PE|f?eEOn+meE4}J0BY+GUY|a1 zzUu|pI_Y7bO8)^Ecm#=YX}d^?^wDTFQMV&oSFh98G0%MRd|Y?=w4O))+jgwPD$OKh zGIMtfSt04`%?rcmK@Z>qeZ4mz#~TAR$>Cx5j~>kw0z8sCG2#O$@-BiCVh{y3cbs~ixwvgnfEFKA(elSCm zaYQZ09GiuBmj4^*udFT-JjsgTD(V5DLFDjs-kBBc*$3$4*pc((%Y-CYgt+HcYrrC~i9Gc$@RFBHy8ZbD7( zQ%XQn(opEK9*mlZ5t#8gZGyDyOq5veN?@dAE9~utZ)(uc%zmbOC~L-IC{sZv34Tft z!f_+N@AsmB3?~(rFH1{3HF#!~9Ig@T4uaB9Ufn_jW#lyg2vJt;!pTdx+hbz=E}8Ug zQtwTq_gNGf2_qK2QL2Q%hIvvhO5K`WE zc2E{0?O<;mFRAxo67sfpaoZ)2VfG*yxtg*UBqKyr7e%_-^LdX&ms{M^!`I;Gfq@UO zKVO7b1Q*j8$|dDD+Hg`je|Q?P?Ay^3?_UZ<@sm+v0AiMZ{fTVR8grS0D`^u+{IsHt`n-ni1n#%d^ycVw4f*t&$yqp(a>r)2wK zePXd}YX5-}d_Sm&(xv7gr5N9ZX{u-MqmBBvNumIOj79zUhVM$ zOpCZ&)k?OuRTAW2RdF8pI0{o6l3TSbBU(T_b{ZhQExZu0_~@XUlUe)=WsFWqAd`^l zCr{i_jP$_010@p3vi_FdXj8&tTs)g1?e%wBL-BX=q|K*#_M#$Q$F#Kqrkx`--RzQ% zC1NzsH%|M*BKFCbA`WC(Qe-hTC1pJr6eb8&B!7={k@j?CbKlRBRGIe68nVrV&nHJNiBPRW;wOa zb)F+C4LRfbPxUpH2R|NR>MZzA>b0uoa6j=%)t=sAjjDMnM)%|7jtq}@9Z^yr1v&UA z4h6N7V$)XlXljNk+`A-T4(Hq{;e`IuP4==POBxcWsW1D$kp0aODHcHI{fHexa7#Xh zQWeX~^6T2TWeIUEC(I4g;o1a4k*73M^{AfY%%F93rXle|+msr|eqL{=(pk4-y3=`1 zod}#30IT2EIBla@D-b@bg@A z{$7gXVz@8DT>?L&^+mXEDBirfxB8FU;v7T(lTKZA?RSx@HvvzV7Y21NB6_yGAASq} zNV4sZqW!>RA+Wshs>`S9H4eL0Ee0~H_4L{$Z@$rk>A9b!`tis24VmkbKwu@Kmp)w0 z&B>{RTn9x$EhR5(G(fpuAN+o_By99DWQ=3*`+Ei0O&9ET_PU|*S1c+oj=qG5{iX6E zhT9Fg&7f;rbO;pvCJuyrNg!DBv!op$UZUOrkX|980mzY^}dDvz(zEKq1qahm#QiB3y4q(T4r=ubE`HxL+5#)q+t zJwP8P#d4FV%6PO?<}(KGa^liM@tewUZeD8b;&A(hM;Q9m+vqP97#H(p5y*A=$DBdB z(WzUYG!5zf6MN!SY&rE{Ri0HV>~zf z{xvp((+@N5|Dgt>iP{z5L35lki2Z*$|AI9)9!<9R{p(l*IJl1m_@{0l^d42j}<&QM=7)RkJSV#F75iLxYSXK5khYZ5O$?bC_J*p9;9d zLmC%om^~hEu_QbIGH8wKSI&0!(=ZCMIOg7}Ol2LX%cs$C+AH)3 z1My#zLRtcEcIROxzAUPU7aPhT+u>lFdJwQxLpv-OdU4!M-PP}O-GH%#Ow!EIY>;Hq zU-p`bK|RPMSBKY?B-(o}h;DpKhz!dBq&d={U!K0njG&-&w&AV`#gg(^uPDi+7rzAL z4H97mK8wQ09RGatMax_HZKY!#>g?z_a@uZ}dC}C6%Om-XH<_&#&Tja_^DN^`<}Nn3_4GxmNa>)m@=^>X;3Yz`;px-r{t^6t;7L9EBh>&ClDk8l6jbtkvuC=dlH{53kq!vRJm~)h&{i8*L zlhrlaC?FiUme4b97M?5`+u6#|R@+b%MBaLNAt;Lk{yAhP>!vkk?ug$|VJWg>W(B|| zS#nRP?~C&F$;D7*@SyegYe29^RxTzNZBSK2a+v8pG44?1QxLT;

    ~`nfHuM?@JDz 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.75.0 diff --git a/hw4/hw/Dockerfile b/hw4/hw/Dockerfile new file mode 100644 index 00000000..a11b8927 --- /dev/null +++ b/hw4/hw/Dockerfile @@ -0,0 +1,33 @@ +FROM python:3.12 + +# Variables d'environnement pour optimiser Python +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +# Mettre à jour et installer gcc (nécessaire pour certaines dépendances) +RUN apt-get update && apt-get install -y gcc + +# Mettre à jour pip +RUN python -m pip install --upgrade pip + +# Créer et définir le répertoire de travail +WORKDIR /app + +# Copier d'abord requirements.txt pour optimiser le cache Docker +COPY requirements.txt . + +# Installer les dépendances Python +RUN pip install --no-cache-dir -r requirements.txt + +# Copier le reste de l'application +COPY . . + +# Exposer le port 8080 +EXPOSE 8080 + +# Commande pour lancer l'application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080", "--reload"] \ No newline at end of file diff --git a/hw4/hw/README.md b/hw4/hw/README.md new file mode 100644 index 00000000..ba9f23c8 --- /dev/null +++ b/hw4/hw/README.md @@ -0,0 +1,123 @@ +# ДЗ + +## Задание - REST API (3 балла) + +Реализовать REST + RPC API для выдуманного интернет магазина. + +Тесты завязаны на объект `app = FastAPI(title="Shop API")` из файла [hw2/hw/shop_api/main.py](./shop_api/main.py), поэтому реализовывайте его используя его + +Ресурсы: + +- корзина (cart) + + Пример структуры ресурса: + + ```json + { + "id": 123, // идентификатор корзины + "items": [ // список товаров в корзине + { + "id": 1, // id товара + "name": "Туалетная бумага \"Поцелуй\", рулон", // название + "quantity": 3, // количество товара в корзине + "available": true // доступе ли (не удален ли) товар + }, + { + "id": 535, + "name": "Золотая цепочка \"Abendsonne\"", + "quantity": 1, + "available": false, + }, + ], + "price": 234.4 // общая сумма заказа + } + ``` + +- товар (item) + + Пример структуры ресурса: + + ```json + { + "id": 321, // идентификатор товара + "name": "Молоко \"Буреночка\" 1л.", // наименование товара + "price": 159.99, // цена товара + "deleted": false // удален ли товар, по умолчанию false + } + ``` + +Запросы для реализации: + +- cart + - `POST cart` - создание, работает как RPC, не принимает тело, возвращает + идентификатор + - `GET /cart/{id}` - получение корзины по `id` + - `GET /cart` - получение списка корзин с query-параметрами + - `offset` - неотрицательное целое число, смещение по списку (опционально, + по-умолчанию 0) + - `limit` - положительное целое число, ограничение на количество + (опционально, по-умолчанию 10) + - `min_price` - число с плавающей запятой, минимальная цена включительно + (опционально, если нет, не учитывает в фильтре) + - `max_price` - число с плавающей запятой, максимальная цена включительно + (опционально, если нет, не учитывает в фильтре) + - `min_quantity` - неотрицательное целое число, минимальное общее число + товаров включительно (опционально, если нет, не учитывается в фильтре) + - `max_quantity` - неотрицательное целое число, максимальное общее число + товаров включительно (опционально, если нет, не учитывается в фильтре) + - `POST /cart/{cart_id}/add/{item_id}` - добавление в корзину с `cart_id` + предмета с `item_id`, если товар уже есть, то увеличивается его количество +- item + - `POST /item` - добавление нового товара + - `GET /item/{id}` - получение товара по `id` + - `GET /item` - получение списка товаров с query-параметрами + - `offset` - неотрицательное целое число, смещение по списку (опционально, + по-умолчанию 0) + - `limit` - положительное целое число, ограничение на количество + (опционально, по-умолчанию 10) + - `min_price` - число с плавающей запятой, минимальная цена (опционально, + если нет, не учитывает в фильтре) + - `max_price` - число с плавающей запятой, максимальная цена (опционально, + если нет, не учитывает в фильтре) + - `show_deleted` - булевая переменная, показывать ли удаленные товары (по + умолчанию `False`) + - `PUT /item/{id}` - замена товара по `id` (создание запрещено, только замена + существующего) + - `PATCH /item/{id}` - частичное обновление товара по `id` (разрешено менять + все поля, кроме `deleted`) + - `DELETE /item/{id}` - удаление товара по `id` (товар помечается как + удаленный) + +Способ хранение данных на усмотрение. + +Более подробные детали и требования к работе методов смотрите в тестах. +Модификация тестов при потребности допускается (но не смысловая). + +Чтобы запустить тесты только для этого задания вызовите: + +```sh +pytest -vv --showlocals --strict ./hw2/test_homework_2_1.py +``` + +Если получаете ошибку на подобии `No module named 'shop_api'` +Понадобится еще такая команда, после которой можно запускать тесты: + +```sh +export PYTHONPATH=${PWD}/hw2/hw +``` + +## Доп. Задание - WebSocket (+ доп балл) + +Реализовать чат для пользователей в отдельных комнатах (в примере один на всех). + +Пользователи подключаются к чату по WebSocket ручке `/chat/{chat_name}`. +Пользователи, которые ввели один и тот же `chat_name` буду подключены к одному +чату (то есть будут получать сообщения друг от друга). Пользователи не +подключенные к диалогу не будут получать сообщения. + +Сообщение - текст в теле сообщения от клиента. Сервер должен broadcast'ить +сообщения на других пользователей в своем чате. Каждому клиенту сервер +присваивает случайное имя и дополняет каждое сообщение именем пользователя в +начале в следующем виде: `{username} :: {message}`. + +Если делаете его, напишите, пожалуйста, прямо в PR-e об этом. Мне будет сильно проще это заметить<3 diff --git a/hw4/hw/__init__.py b/hw4/hw/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/hw/database.py b/hw4/hw/database.py new file mode 100644 index 00000000..5fccedc5 --- /dev/null +++ b/hw4/hw/database.py @@ -0,0 +1,24 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.ext.declarative import declarative_base +import os + +# URL de connexion à PostgreSQL +DATABASE_URL = "postgresql://postgres:password@postgres:5432/shop_db" + +# Moteur de connexion +engine = create_engine(DATABASE_URL) + +# Session pour interagir avec la base +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Base pour tous nos modèles +Base = declarative_base() + +# Fonction pour obtenir une session (utilisée dans les routes) +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/hw4/hw/docker-compose.yml b/hw4/hw/docker-compose.yml new file mode 100644 index 00000000..536a37b2 --- /dev/null +++ b/hw4/hw/docker-compose.yml @@ -0,0 +1,34 @@ +version: '3.8' + +services: + postgres: + image: postgres:15 + container_name: shop_postgres + environment: + POSTGRES_DB: shop_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + api: + build: . + ports: + - "8080:8080" + environment: + - DATABASE_URL=postgresql://postgres:password@postgres:5432/shop_db + depends_on: + postgres: + condition: service_healthy + volumes: + - .:/app + +volumes: + postgres_data: \ No newline at end of file diff --git a/hw4/hw/init_db.py b/hw4/hw/init_db.py new file mode 100644 index 00000000..73daefc8 --- /dev/null +++ b/hw4/hw/init_db.py @@ -0,0 +1,12 @@ +from database import engine, Base +from shop_api.item.store.models import ItemDB +from shop_api.cart.store.models import CartDB, CartItemDB + +def create_tables(): + print("Création des tables dans la base de données...") + Base.metadata.create_all(bind=engine) + print("✅ Tables créées avec succès!") + print("📊 Tables créées : items, carts, cart_items") + +if __name__ == "__main__": + create_tables() \ No newline at end of file diff --git a/hw4/hw/main.py b/hw4/hw/main.py new file mode 100644 index 00000000..ac75017c --- /dev/null +++ b/hw4/hw/main.py @@ -0,0 +1,13 @@ +from fastapi import FastAPI + +from shop_api.cart.routers import router as cart +from shop_api.item.routers import router as item + +app = FastAPI(title="Shop API") + +app.include_router(cart) +app.include_router(item) + +@app.get("/") +async def root(): + return {"message": "API Shop is running"} \ No newline at end of file diff --git a/hw4/hw/python b/hw4/hw/python new file mode 100644 index 00000000..e69de29b diff --git a/hw4/hw/report.txt b/hw4/hw/report.txt new file mode 100644 index 00000000..ee0ceabe --- /dev/null +++ b/hw4/hw/report.txt @@ -0,0 +1,58 @@ +Тестовые данные созданы с использованием существующих моделей + +================================================== +DIRTY READ - READ UNCOMMITTED +================================================== +Транзакция 1: Изменяю цену TestItem1 +50€ (без коммита) +Транзакция 1: Изменил цену с 100.0 на 150.0 +Транзакция 2: Читаю цену TestItem1... +Транзакция 2: Вижу 100.0€ -> DIRTY READ! +Транзакция 1: Делаю rollback! + +================================================== +НЕТ DIRTY READ - READ COMMITTED +================================================== +Транзакция 1: Изменяю цену TestItem1 +50€ (без коммита) +Транзакция 1: Изменил цену с 100.0 на 150.0 +Транзакция 2: Читаю цену TestItem1... +Транзакция 2: Вижу 100.0€ -> Чистые данные! +Транзакция 1: Делаю rollback! + +================================================== +NON-REPEATABLE READ - READ COMMITTED +================================================== +Транзакция 2: Первое чтение -> 200.0€ +Транзакция 1: Изменяю TestItem2 +100€ и коммит +Транзакция 1: Изменил цену с 200.0 на 300.0 и закоммитил +Транзакция 2: Второе чтение -> 300.0€ +NON-REPEATABLE READ обнаружен! + +================================================== +НЕТ NON-REPEATABLE READ - REPEATABLE READ +================================================== +Транзакция 2: Первое чтение -> 300.0€ +Транзакция 1: Изменяю TestItem2 +150€ и коммит +Транзакция 1: Изменил цену с 300.0 на 450.0 и закоммитил +Транзакция 2: Второе чтение -> 300.0€ +Нет Non-Repeatable Read с REPEATABLE READ! + +================================================== +PHANTOM READ - REPEATABLE READ +================================================== +Транзакция 2: Первый подсчет -> 5 товаров +Транзакция 1: Добавляю новый товар 'PhantomItem' +Транзакция 1: Новый товар добавлен и закоммичен! +Транзакция 2: Второй подсчет -> 5 товаров + +================================================== +НЕТ PHANTOM READ - SERIALIZABLE +================================================== +Транзакция 2: Первый подсчет -> 6 товаров +Транзакция 1: Добавляю новый товар 'SerializableItem' +Транзакция 1: Новый товар добавлен и закоммичен! +Транзакция 2: Второй подсчет -> 6 товаров +Нет Phantom Read с SERIALIZABLE! + +================================================== +ДЕМОНСТРАЦИЯ ЗАВЕРШЕНА! +================================================== \ No newline at end of file diff --git a/hw4/hw/requirements.txt b/hw4/hw/requirements.txt new file mode 100644 index 00000000..564d70cc --- /dev/null +++ b/hw4/hw/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.104.1 +uvicorn==0.24.0 +sqlalchemy==2.0.25 +psycopg2-binary==2.9.9 +python-dotenv==1.0.0 +alembic==1.13.0 +pydantic==2.5.0 \ No newline at end of file diff --git a/hw4/hw/shop_api/__init__.py b/hw4/hw/shop_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/hw/shop_api/cart/contracts.py b/hw4/hw/shop_api/cart/contracts.py new file mode 100644 index 00000000..c052cee3 --- /dev/null +++ b/hw4/hw/shop_api/cart/contracts.py @@ -0,0 +1,19 @@ +from __future__ import annotations +from pydantic import BaseModel +from shop_api.cart.store.models import CartEntity, CartInfo, CartItemInfo + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + +class CartResponse(BaseModel): + id: int + items: list[CartItemResponse] + price: float + + @staticmethod + def from_entity(entity: CartEntity) -> CartResponse: + items = [CartItemResponse(id=item.id, name=item.name, quantity=item.quantity, available=item.available) for item in entity.info.items] + return CartResponse(id=entity.id, items=items, price=entity.info.price) \ No newline at end of file diff --git a/hw4/hw/shop_api/cart/routers.py b/hw4/hw/shop_api/cart/routers.py new file mode 100644 index 00000000..612ef4db --- /dev/null +++ b/hw4/hw/shop_api/cart/routers.py @@ -0,0 +1,101 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response, Depends +from sqlalchemy.orm import Session +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + +from shop_api.cart import store +from shop_api.cart.contracts import CartResponse +import shop_api.item.store +from database import get_db + +router = APIRouter(prefix="/cart") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_cart( + response: Response, + db: Session = Depends(get_db) +) -> CartResponse: + entity = store.create(db) + response.headers["location"] = f"/cart/{entity.id}" + return CartResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested cart", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested cart as one was not found", + }, + }, +) +async def get_cart_by_id( + id: int, + db: Session = Depends(get_db) +): + entity = store.get_one(id, db) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /cart/{id} was not found", + ) + + return CartResponse.from_entity(entity) + + +@router.get("/") +async def get_cart_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + min_quantity: Annotated[NonNegativeFloat, Query()] | None = None, + max_quantity: Annotated[NonNegativeFloat, Query()] | None = None, + db: Session = Depends(get_db) +): + return [ + CartResponse.from_entity(e) + for e in store.get_many(db, offset, limit, min_price, max_price, min_quantity, max_quantity) + ] + + +@router.post("/{cart_id}/add/{item_id}") +async def add_to_cart( + cart_id: int, + item_id: int, + db: Session = Depends(get_db) +): + # Récupère l'item depuis la base + item_entity = shop_api.item.store.get_one(item_id, db) + + if not item_entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Item {item_id} not found", + ) + + # Ajoute l'item au panier + entity = store.add(cart_id, item_entity, db) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Cart {cart_id} not found", + ) + + return CartResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_cart( + id: int, + db: Session = Depends(get_db) +) -> Response: + store.delete(id, db) + return Response("", status_code=HTTPStatus.NO_CONTENT) \ No newline at end of file diff --git a/hw4/hw/shop_api/cart/store/__init__.py b/hw4/hw/shop_api/cart/store/__init__.py new file mode 100644 index 00000000..e14c47c2 --- /dev/null +++ b/hw4/hw/shop_api/cart/store/__init__.py @@ -0,0 +1,13 @@ +from .models import CartItemInfo, CartEntity, CartInfo +from .queries import add, delete, get_many, get_one, create + +__all__ = [ + "CartEntity", + "CartInfo", + "CartItemInfo", + "add", + "delete", + "get_many", + "get_one", + "create", +] diff --git a/hw4/hw/shop_api/cart/store/models.py b/hw4/hw/shop_api/cart/store/models.py new file mode 100644 index 00000000..9d55e963 --- /dev/null +++ b/hw4/hw/shop_api/cart/store/models.py @@ -0,0 +1,38 @@ +from sqlalchemy import Column, Integer, ForeignKey, DateTime +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from database import Base + +class CartDB(Base): + __tablename__ = "carts" + id = Column(Integer, primary_key=True, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + items = relationship("CartItemDB", back_populates="cart") + +class CartItemDB(Base): + __tablename__ = "cart_items" + id = Column(Integer, primary_key=True, index=True) + cart_id = Column(Integer, ForeignKey("carts.id")) + item_id = Column(Integer, ForeignKey("items.id")) + quantity = Column(Integer, default=1) + cart = relationship("CartDB", back_populates="items") + item = relationship("ItemDB") + +from dataclasses import dataclass + +@dataclass(slots=True) +class CartItemInfo: + id: int + name: str + quantity: int + available: bool + +@dataclass(slots=True) +class CartInfo: + items: list[CartItemInfo] + price: float + +@dataclass(slots=True) +class CartEntity: + id: int + info: CartInfo \ No newline at end of file diff --git a/hw4/hw/shop_api/cart/store/queries.py b/hw4/hw/shop_api/cart/store/queries.py new file mode 100644 index 00000000..a868b394 --- /dev/null +++ b/hw4/hw/shop_api/cart/store/queries.py @@ -0,0 +1,60 @@ +from sqlalchemy.orm import Session +from typing import Iterable +from .models import CartDB, CartItemDB, CartEntity, CartInfo, CartItemInfo +from shop_api.item.store.models import ItemDB + +def create(db: Session) -> CartEntity: + db_cart = CartDB() + db.add(db_cart) + db.commit() + db.refresh(db_cart) + return CartEntity(id=db_cart.id, info=CartInfo(items=[], price=0.0)) + +def add(cart_id: int, item_entity, db: Session) -> CartEntity: + db_cart = db.query(CartDB).filter(CartDB.id == cart_id).first() + if not db_cart: + return None + existing_item = db.query(CartItemDB).filter(CartItemDB.cart_id == cart_id, CartItemDB.item_id == item_entity.id).first() + if existing_item: + existing_item.quantity += 1 + else: + new_item = CartItemDB(cart_id=cart_id, item_id=item_entity.id, quantity=1) + db.add(new_item) + db.commit() + return get_one(cart_id, db) + +def delete(id: int, db: Session) -> None: + db_cart = db.query(CartDB).filter(CartDB.id == id).first() + if db_cart: + db.delete(db_cart) + db.commit() + +def get_one(id: int, db: Session) -> CartEntity | None: + db_cart = db.query(CartDB).filter(CartDB.id == id).first() + if not db_cart: + return None + total_price = 0.0 + cart_items = [] + for cart_item in db_cart.items: + item = cart_item.item + item_total = item.price * cart_item.quantity + total_price += item_total + cart_items.append(CartItemInfo(id=item.id, name=item.name, quantity=cart_item.quantity, available=not item.deleted)) + return CartEntity(id=db_cart.id, info=CartInfo(items=cart_items, price=total_price)) + +def get_many(db: Session, offset: int = 0, limit: int = 10, min_price: float = None, max_price: float = None, min_quantity: int = None, max_quantity: int = None) -> Iterable[CartEntity]: + db_carts = db.query(CartDB).offset(offset).limit(limit).all() + for db_cart in db_carts: + cart_entity = get_one(db_cart.id, db) + if not cart_entity: + continue + if min_price is not None and cart_entity.info.price < min_price: + continue + if max_price is not None and cart_entity.info.price > max_price: + continue + total_quantity = sum(item.quantity for item in cart_entity.info.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 + yield cart_entity \ No newline at end of file diff --git a/hw4/hw/shop_api/item/contracts.py b/hw4/hw/shop_api/item/contracts.py new file mode 100644 index 00000000..5f2766f1 --- /dev/null +++ b/hw4/hw/shop_api/item/contracts.py @@ -0,0 +1,29 @@ +from __future__ import annotations +from pydantic import BaseModel, ConfigDict +from shop_api.item.store.models import ItemEntity, ItemInfo, PatchItemInfo + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool + + @staticmethod + def from_entity(entity: ItemEntity) -> ItemResponse: + return ItemResponse(id=entity.id, name=entity.info.name, price=entity.info.price, deleted=entity.info.deleted) + +class ItemRequest(BaseModel): + name: str + price: float + deleted: bool = False + + def as_item_info(self) -> ItemInfo: + return ItemInfo(name=self.name, price=self.price, deleted=self.deleted) + +class PatchItemRequest(BaseModel): + name: str | None = None + price: float | None = None + model_config = ConfigDict(extra="forbid") + + def as_patch_item_info(self) -> PatchItemInfo: + return PatchItemInfo(name=self.name, price=self.price) \ No newline at end of file diff --git a/hw4/hw/shop_api/item/routers.py b/hw4/hw/shop_api/item/routers.py new file mode 100644 index 00000000..fc237136 --- /dev/null +++ b/hw4/hw/shop_api/item/routers.py @@ -0,0 +1,127 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response, Depends +from sqlalchemy.orm import Session +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + +from shop_api.item import store +from shop_api.item.contracts import ItemRequest, ItemResponse, PatchItemRequest +from database import get_db + +router = APIRouter(prefix="/item") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_item( + info: ItemRequest, + response: Response, + db: Session = Depends(get_db) +) -> ItemResponse: + entity = store.add(info.as_item_info(), db) + response.headers["location"] = f"/item/{entity.id}" + return ItemResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested item as one was not found", + }, + }, +) +async def get_item_by_id( + id: int, + db: Session = Depends(get_db) +): + entity = store.get_one(id, db) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.get("/") +async def get_item_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + show_deleted: Annotated[bool, Query()] = False, + db: Session = Depends(get_db) +): + return [ + ItemResponse.from_entity(e) + for e in store.get_many(db, offset, limit, min_price, max_price, show_deleted) + ] + + +@router.patch( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def patch_item( + id: int, + info: PatchItemRequest, + db: Session = Depends(get_db) +) -> ItemResponse: + entity = store.patch(id, info.as_patch_item_info(), db) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.put( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully updated or upserted item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def put_item( + id: int, + info: ItemRequest, + db: Session = Depends(get_db) +) -> ItemResponse: + entity = store.update(id, info.as_item_info(), db) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_item( + id: int, + db: Session = Depends(get_db) +) -> Response: + store.delete(id, db) + return Response("", status_code=HTTPStatus.NO_CONTENT) \ No newline at end of file diff --git a/hw4/hw/shop_api/item/store/__init__.py b/hw4/hw/shop_api/item/store/__init__.py new file mode 100644 index 00000000..bf69aa56 --- /dev/null +++ b/hw4/hw/shop_api/item/store/__init__.py @@ -0,0 +1,14 @@ +from .models import PatchItemInfo, ItemEntity, ItemInfo +from .queries import add, delete, get_many, get_one, patch, update + +__all__ = [ + "ItemEntity", + "ItemInfo", + "PatchItemInfo", + "add", + "delete", + "get_many", + "get_one", + "update", + "patch", +] diff --git a/hw4/hw/shop_api/item/store/models.py b/hw4/hw/shop_api/item/store/models.py new file mode 100644 index 00000000..1ae1f37e --- /dev/null +++ b/hw4/hw/shop_api/item/store/models.py @@ -0,0 +1,27 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean +from database import Base + +class ItemDB(Base): + __tablename__ = "items" + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + price = Column(Float, nullable=False) + deleted = Column(Boolean, default=False) + +from dataclasses import dataclass + +@dataclass(slots=True) +class ItemInfo: + name: str + price: float + deleted: bool + +@dataclass(slots=True) +class ItemEntity: + id: int + info: ItemInfo + +@dataclass(slots=True) +class PatchItemInfo: + name: str | None = None + price: float | None = None \ No newline at end of file diff --git a/hw4/hw/shop_api/item/store/queries.py b/hw4/hw/shop_api/item/store/queries.py new file mode 100644 index 00000000..180dfb51 --- /dev/null +++ b/hw4/hw/shop_api/item/store/queries.py @@ -0,0 +1,57 @@ +from sqlalchemy.orm import Session +from typing import Iterable +from .models import ItemDB, ItemEntity, ItemInfo, PatchItemInfo + +def add(info: ItemInfo, db: Session) -> ItemEntity: + db_item = ItemDB(name=info.name, price=info.price, deleted=info.deleted) + db.add(db_item) + db.commit() + db.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo(name=db_item.name, price=db_item.price, deleted=db_item.deleted)) + +def delete(id: int, db: Session) -> None: + db_item = db.query(ItemDB).filter(ItemDB.id == id).first() + if db_item: + db.delete(db_item) + db.commit() + +def get_one(id: int, db: Session) -> ItemEntity | None: + db_item = db.query(ItemDB).filter(ItemDB.id == id).first() + if not db_item: + return None + return ItemEntity(id=db_item.id, info=ItemInfo(name=db_item.name, price=db_item.price, deleted=db_item.deleted)) + +def get_many(db: Session, offset: int = 0, limit: int = 10, min_price: float = None, max_price: float = None, show_deleted: bool = False) -> Iterable[ItemEntity]: + query = db.query(ItemDB) + if min_price is not None: + query = query.filter(ItemDB.price >= min_price) + if max_price is not None: + query = query.filter(ItemDB.price <= max_price) + if not show_deleted: + query = query.filter(ItemDB.deleted == False) + db_items = query.offset(offset).limit(limit).all() + for db_item in db_items: + yield ItemEntity(id=db_item.id, info=ItemInfo(name=db_item.name, price=db_item.price, deleted=db_item.deleted)) + +def update(id: int, info: ItemInfo, db: Session) -> ItemEntity | None: + db_item = db.query(ItemDB).filter(ItemDB.id == id).first() + if not db_item: + return None + db_item.name = info.name + db_item.price = info.price + db_item.deleted = info.deleted + db.commit() + db.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo(name=db_item.name, price=db_item.price, deleted=db_item.deleted)) + +def patch(id: int, patch_info: PatchItemInfo, db: Session) -> ItemEntity | None: + db_item = db.query(ItemDB).filter(ItemDB.id == id).first() + if not db_item: + return None + if patch_info.name is not None: + db_item.name = patch_info.name + if patch_info.price is not None: + db_item.price = patch_info.price + db.commit() + db.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo(name=db_item.name, price=db_item.price, deleted=db_item.deleted)) \ No newline at end of file diff --git a/hw4/hw/shop_api/uvicorn b/hw4/hw/shop_api/uvicorn new file mode 100644 index 00000000..e69de29b diff --git a/hw4/hw/test_homework2.py b/hw4/hw/test_homework2.py new file mode 100644 index 00000000..60a1f36a --- /dev/null +++ b/hw4/hw/test_homework2.py @@ -0,0 +1,284 @@ +from http import HTTPStatus +from typing import Any +from uuid import uuid4 + +import pytest +from faker import Faker +from fastapi.testclient import TestClient + +from shop_api.main import app + +client = TestClient(app) +faker = Faker() + + +@pytest.fixture() +def existing_empty_cart_id() -> int: + return client.post("/cart").json()["id"] + + +@pytest.fixture(scope="session") +def existing_items() -> list[int]: + items = [ + { + "name": f"Тестовый товар {i}", + "price": faker.pyfloat(positive=True, min_value=10.0, max_value=500.0), + } + for i in range(10) + ] + + return [client.post("/item", json=item).json()["id"] for item in items] + + +@pytest.fixture(scope="session", autouse=True) +def existing_not_empty_carts(existing_items: list[int]) -> list[int]: + carts = [] + + for i in range(20): + cart_id: int = client.post("/cart").json()["id"] + for item_id in faker.random_elements(existing_items, unique=False, length=i): + client.post(f"/cart/{cart_id}/add/{item_id}") + + carts.append(cart_id) + + return carts + + +@pytest.fixture() +def existing_not_empty_cart_id( + existing_empty_cart_id: int, + existing_items: list[int], +) -> int: + for item_id in faker.random_elements(existing_items, unique=False, length=3): + client.post(f"/cart/{existing_empty_cart_id}/add/{item_id}") + + return existing_empty_cart_id + + +@pytest.fixture() +def existing_item() -> dict[str, Any]: + return client.post( + "/item", + json={ + "name": f"Тестовый товар {uuid4().hex}", + "price": faker.pyfloat(min_value=10.0, max_value=100.0), + }, + ).json() + + +@pytest.fixture() +def deleted_item(existing_item: dict[str, Any]) -> dict[str, Any]: + item_id = existing_item["id"] + client.delete(f"/item/{item_id}") + + existing_item["deleted"] = True + return existing_item + + +def test_post_cart() -> None: + response = client.post("/cart") + + assert response.status_code == HTTPStatus.CREATED + assert "location" in response.headers + assert "id" in response.json() + + +@pytest.mark.parametrize( + ("cart", "not_empty"), + [ + ("existing_empty_cart_id", False), + ("existing_not_empty_cart_id", True), + ], +) +def test_get_cart(request, cart: int, not_empty: bool) -> None: + cart_id = request.getfixturevalue(cart) + + response = client.get(f"/cart/{cart_id}") + + assert response.status_code == HTTPStatus.OK + response_json = response.json() + + len_items = len(response_json["items"]) + assert len_items > 0 if not_empty else len_items == 0 + + if not_empty: + price = 0 + + for item in response_json["items"]: + item_id = item["id"] + price += client.get(f"/item/{item_id}").json()["price"] * item["quantity"] + + assert response_json["price"] == pytest.approx(price, 1e-8) + else: + assert response_json["price"] == 0.0 + + +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({}, HTTPStatus.OK), + ({"offset": 1, "limit": 2}, HTTPStatus.OK), + ({"min_price": 1000.0}, HTTPStatus.OK), + ({"max_price": 20.0}, HTTPStatus.OK), + ({"min_quantity": 1}, HTTPStatus.OK), + ({"max_quantity": 0}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_cart_list(query: dict[str, Any], status_code: int): + response = client.get("/cart", params=query) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + + assert isinstance(data, list) + + if "min_price" in query: + assert all(item["price"] >= query["min_price"] for item in data) + + if "max_price" in query: + assert all(item["price"] <= query["max_price"] for item in data) + + quantity = sum(item["quantity"] for cart in data for item in cart["items"]) + + if "min_quantity" in query: + assert quantity >= query["min_quantity"] + + if "max_quantity" in query: + assert quantity <= query["max_quantity"] + + +def test_post_item() -> None: + item = {"name": "test item", "price": 9.99} + response = client.post("/item", json=item) + + assert response.status_code == HTTPStatus.CREATED + + data = response.json() + assert item["price"] == data["price"] + assert item["name"] == data["name"] + + +def test_get_item(existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = client.get(f"/item/{item_id}") + + assert response.status_code == HTTPStatus.OK + assert response.json() == existing_item + + +@pytest.mark.parametrize( + ("query", "status_code"), + [ + ({"offset": 2, "limit": 5}, HTTPStatus.OK), + ({"min_price": 5.0}, HTTPStatus.OK), + ({"max_price": 5.0}, HTTPStatus.OK), + ({"show_deleted": True}, HTTPStatus.OK), + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_get_item_list(query: dict[str, Any], status_code: int) -> None: + response = client.get("/item", params=query) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + + assert isinstance(data, list) + + if "min_price" in query: + assert all(item["price"] >= query["min_price"] for item in data) + + if "max_price" in query: + assert all(item["price"] <= query["max_price"] for item in data) + + if "show_deleted" in query and query["show_deleted"] is False: + assert all(item["deleted"] is False for item in data) + + +@pytest.mark.parametrize( + ("body", "status_code"), + [ + ({}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"price": 9.99}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"name": "new name", "price": 9.99}, HTTPStatus.OK), + ], +) +def test_put_item( + existing_item: dict[str, Any], + body: dict[str, Any], + status_code: int, +) -> None: + item_id = existing_item["id"] + response = client.put(f"/item/{item_id}", json=body) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + new_item = existing_item.copy() + new_item.update(body) + assert response.json() == new_item + + +@pytest.mark.parametrize( + ("item", "body", "status_code"), + [ + ("deleted_item", {}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("deleted_item", {"name": "new name", "price": 9.99}, HTTPStatus.NOT_MODIFIED), + ("existing_item", {}, HTTPStatus.OK), + ("existing_item", {"price": 9.99}, HTTPStatus.OK), + ("existing_item", {"name": "new name", "price": 9.99}, HTTPStatus.OK), + ( + "existing_item", + {"name": "new name", "price": 9.99, "odd": "value"}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ( + "existing_item", + {"name": "new name", "price": 9.99, "deleted": True}, + HTTPStatus.UNPROCESSABLE_ENTITY, + ), + ], +) +def test_patch_item(request, item: str, body: dict[str, Any], status_code: int) -> None: + item_data: dict[str, Any] = request.getfixturevalue(item) + item_id = item_data["id"] + response = client.patch(f"/item/{item_id}", json=body) + + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + patch_response_body = response.json() + + response = client.get(f"/item/{item_id}") + patched_item = response.json() + + assert patched_item == patch_response_body + + +def test_delete_item(existing_item: dict[str, Any]) -> None: + item_id = existing_item["id"] + + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + + response = client.get(f"/item/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + + response = client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK diff --git a/hw4/hw/transaction_demo.py b/hw4/hw/transaction_demo.py new file mode 100644 index 00000000..e00c6c7c --- /dev/null +++ b/hw4/hw/transaction_demo.py @@ -0,0 +1,355 @@ +import threading +import time +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +from database import SessionLocal +from shop_api.item.store.models import ItemDB +from shop_api.cart.store.models import CartDB + +# Конфигурация базы данных +DATABASE_URL = "postgresql://postgres:password@postgres:5432/shop_db" +engine = create_engine(DATABASE_URL) +Session = sessionmaker(bind=engine) + +def setup_test_data(): + """Подготовка тестовых данных используя существующие модели""" + session = Session() + try: + # Очистка тестовых данных + session.query(ItemDB).filter(ItemDB.name.in_(["TestItem1", "TestItem2", "TestItem3"])).delete() + session.commit() + + # Создание тестовых товаров используя модель ItemDB + test_items = [ + ItemDB(name="TestItem1", price=100.0, deleted=False), + ItemDB(name="TestItem2", price=200.0, deleted=False), + ItemDB(name="TestItem3", price=300.0, deleted=False) + ] + + session.add_all(test_items) + session.commit() + print("Тестовые данные созданы с использованием существующих моделей") + + except Exception as e: + session.rollback() + print(f"Ошибка: {e}") + finally: + session.close() + +def dirty_read_demo(): + """Демонстрация Dirty Read (грязное чтение)""" + print("\n" + "="*50) + print("DIRTY READ - READ UNCOMMITTED") + print("="*50) + + def transaction1(): + """Транзакция, которая изменяет и откатывает""" + session = Session() + try: + print("Транзакция 1: Изменяю цену TestItem1 +50€ (без коммита)") + session.execute(text("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED")) + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem1").first() + original_price = item.price + item.price = item.price + 50 + print(f"Транзакция 1: Изменил цену с {original_price} на {item.price}") + + time.sleep(3) # Пауза для чтения Т2 + print("Транзакция 1: Делаю rollback!") + session.rollback() + + finally: + session.close() + + def transaction2(): + """Транзакция, которая читает незакоммиченные данные""" + session = Session() + try: + time.sleep(1) # Ждет изменения Т1 + print("Транзакция 2: Читаю цену TestItem1...") + session.execute(text("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED")) + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem1").first() + print(f"Транзакция 2: Вижу {item.price}€ -> DIRTY READ!") + + finally: + session.close() + + t1 = threading.Thread(target=transaction1) + t2 = threading.Thread(target=transaction2) + + t1.start() + t2.start() + t1.join() + t2.join() + +def no_dirty_read_demo(): + """Демонстрация отсутствия Dirty Read с READ COMMITTED""" + print("\n" + "="*50) + print("НЕТ DIRTY READ - READ COMMITTED") + print("="*50) + + def transaction1(): + session = Session() + try: + print("Транзакция 1: Изменяю цену TestItem1 +50€ (без коммита)") + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem1").first() + original_price = item.price + item.price = item.price + 50 + print(f"Транзакция 1: Изменил цену с {original_price} на {item.price}") + + time.sleep(3) + print("Транзакция 1: Делаю rollback!") + session.rollback() + + finally: + session.close() + + def transaction2(): + session = Session() + try: + time.sleep(1) + print("Транзакция 2: Читаю цену TestItem1...") + session.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem1").first() + print(f"Транзакция 2: Вижу {item.price}€ -> Чистые данные!") + + finally: + session.close() + + t1 = threading.Thread(target=transaction1) + t2 = threading.Thread(target=transaction2) + + t1.start() + t2.start() + t1.join() + t2.join() + +def non_repeatable_read_demo(): + """Демонстрация Non-Repeatable Read с READ COMMITTED""" + print("\n" + "="*50) + print("NON-REPEATABLE READ - READ COMMITTED") + print("="*50) + + def transaction1(): + session = Session() + try: + time.sleep(1) + print("Транзакция 1: Изменяю TestItem2 +100€ и коммит") + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + original_price = item.price + item.price = item.price + 100 + session.commit() + print(f"Транзакция 1: Изменил цену с {original_price} на {item.price} и закоммитил") + + finally: + session.close() + + def transaction2(): + session = Session() + try: + session.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + + # Первое чтение + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + price1 = item.price + print(f"Транзакция 2: Первое чтение -> {price1}€") + + time.sleep(2) # Ждет изменения Т1 + + # Второе чтение + session.expire_all() # Сбрасываем кэш + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + price2 = item.price + print(f"Транзакция 2: Второе чтение -> {price2}€") + + if price1 != price2: + print("NON-REPEATABLE READ обнаружен!") + + finally: + session.close() + + t2 = threading.Thread(target=transaction2) + t1 = threading.Thread(target=transaction1) + + t2.start() + t1.start() + t1.join() + t2.join() + +def no_non_repeatable_read_demo(): + """Демонстрация отсутствия Non-Repeatable Read с REPEATABLE READ""" + print("\n" + "="*50) + print("НЕТ NON-REPEATABLE READ - REPEATABLE READ") + print("="*50) + + def transaction1(): + session = Session() + try: + time.sleep(1) + print("Транзакция 1: Изменяю TestItem2 +150€ и коммит") + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + original_price = item.price + item.price = item.price + 150 + session.commit() + print(f"Транзакция 1: Изменил цену с {original_price} на {item.price} и закоммитил") + + finally: + session.close() + + def transaction2(): + session = Session() + try: + session.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + + # Первое чтение + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + price1 = item.price + print(f"Транзакция 2: Первое чтение -> {price1}€") + + time.sleep(2) # Ждет изменения Т1 + + # Второе чтение + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + price2 = item.price + print(f"Транзакция 2: Второе чтение -> {price2}€") + + if price1 == price2: + print("Нет Non-Repeatable Read с REPEATABLE READ!") + + finally: + session.close() + + t2 = threading.Thread(target=transaction2) + t1 = threading.Thread(target=transaction1) + + t2.start() + t1.start() + t1.join() + t2.join() + +def phantom_read_demo(): + """Демонстрация Phantom Read с REPEATABLE READ""" + print("\n" + "="*50) + print("PHANTOM READ - REPEATABLE READ") + print("="*50) + + def transaction1(): + session = Session() + try: + time.sleep(1) + print("Транзакция 1: Добавляю новый товар 'PhantomItem'") + + new_item = ItemDB(name="PhantomItem", price=400.0, deleted=False) + session.add(new_item) + session.commit() + print("Транзакция 1: Новый товар добавлен и закоммичен!") + + finally: + session.close() + + def transaction2(): + session = Session() + try: + session.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + + # Первое чтение + items = session.query(ItemDB).filter(ItemDB.price > 100).all() + count1 = len(items) + print(f"Транзакция 2: Первый подсчет -> {count1} товаров") + + time.sleep(2) # Ждет добавления Т1 + + # Второе чтение + items = session.query(ItemDB).filter(ItemDB.price > 100).all() + count2 = len(items) + print(f"Транзакция 2: Второй подсчет -> {count2} товаров") + + if count1 != count2: + print("PHANTOM READ обнаружен!") + + finally: + session.close() + + t2 = threading.Thread(target=transaction2) + t1 = threading.Thread(target=transaction1) + + t2.start() + t1.start() + t1.join() + t2.join() + +def no_phantom_read_demo(): + """Демонстрация отсутствия Phantom Read с SERIALIZABLE""" + print("\n" + "="*50) + print("НЕТ PHANTOM READ - SERIALIZABLE") + print("="*50) + + def transaction1(): + session = Session() + try: + time.sleep(1) + print("Транзакция 1: Добавляю новый товар 'SerializableItem'") + + new_item = ItemDB(name="SerializableItem", price=500.0, deleted=False) + session.add(new_item) + session.commit() + print("Транзакция 1: Новый товар добавлен и закоммичен!") + + finally: + session.close() + + def transaction2(): + session = Session() + try: + session.execute(text("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")) + + # Первое чтение + items = session.query(ItemDB).filter(ItemDB.price > 100).all() + count1 = len(items) + print(f"Транзакция 2: Первый подсчет -> {count1} товаров") + + time.sleep(2) # Ждет добавления Т1 + + # Второе чтение + items = session.query(ItemDB).filter(ItemDB.price > 100).all() + count2 = len(items) + print(f"Транзакция 2: Второй подсчет -> {count2} товаров") + + if count1 == count2: + print("Нет Phantom Read с SERIALIZABLE!") + + finally: + session.close() + + t2 = threading.Thread(target=transaction2) + t1 = threading.Thread(target=transaction1) + + t2.start() + t1.start() + t1.join() + t2.join() + +if __name__ == "__main__": + print("ДЕМОНСТРАЦИЯ ПРОБЛЕМ ТРАНЗАКЦИЙ") + print("База данных: PostgreSQL с SQLAlchemy") + print("Используются существующие модели ItemDB") + + # Подготовка + setup_test_data() + + # Демонстрации + dirty_read_demo() # 1. Dirty Read с READ UNCOMMITTED + no_dirty_read_demo() # 2. Нет Dirty Read с READ COMMITTED + non_repeatable_read_demo() # 3. Non-repeatable Read с READ COMMITTED + no_non_repeatable_read_demo() # 4. Нет Non-repeatable Read с REPEATABLE READ + phantom_read_demo() # 5. Phantom Read с REPEATABLE READ + no_phantom_read_demo() # 6. Нет Phantom Read с SERIALIZABLE + + print("\n" + "="*50) + print("ДЕМОНСТРАЦИЯ ЗАВЕРШЕНА!") + print("="*50) \ No newline at end of file diff --git a/hw4/rest_example/README.md b/hw4/rest_example/README.md new file mode 100644 index 00000000..dc17981e --- /dev/null +++ b/hw4/rest_example/README.md @@ -0,0 +1,3 @@ +# REST API Example + +Пример REST API как пример из лекции, тут храним данные прямо в приложении (в оперативной памяти), т.к. код чисто для примера, не делайте так в продакшене, пожалуйста diff --git a/hw4/rest_example/__init__.py b/hw4/rest_example/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/rest_example/api/__init__.py b/hw4/rest_example/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/rest_example/api/pokemon/__init__.py b/hw4/rest_example/api/pokemon/__init__.py new file mode 100644 index 00000000..ccd625f1 --- /dev/null +++ b/hw4/rest_example/api/pokemon/__init__.py @@ -0,0 +1,9 @@ +from .contracts import PatchPokemonRequest, PokemonRequest, PokemonResponse +from .routes import router + +__all__ = [ + "PokemonResponse", + "PokemonRequest", + "PatchPokemonRequest", + "router", +] diff --git a/hw4/rest_example/api/pokemon/contracts.py b/hw4/rest_example/api/pokemon/contracts.py new file mode 100644 index 00000000..a985b15b --- /dev/null +++ b/hw4/rest_example/api/pokemon/contracts.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from hw2.rest_example.store.models import ( + PatchPokemonInfo, + PokemonEntity, + PokemonInfo, +) + + +class PokemonResponse(BaseModel): + id: int + name: str + published: bool + + @staticmethod + def from_entity(entity: PokemonEntity) -> PokemonResponse: + return PokemonResponse( + id=entity.id, + name=entity.info.name, + published=entity.info.published, + ) + + +class PokemonRequest(BaseModel): + name: str + published: bool + + def as_pokemon_info(self) -> PokemonInfo: + return PokemonInfo(name=self.name, published=self.published) + + +class PatchPokemonRequest(BaseModel): + name: str | None = None + published: bool | None = None + + model_config = ConfigDict(extra="forbid") + + def as_patch_pokemon_info(self) -> PatchPokemonInfo: + return PatchPokemonInfo(name=self.name, published=self.published) diff --git a/hw4/rest_example/api/pokemon/routes.py b/hw4/rest_example/api/pokemon/routes.py new file mode 100644 index 00000000..ab935c9a --- /dev/null +++ b/hw4/rest_example/api/pokemon/routes.py @@ -0,0 +1,119 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response +from pydantic import NonNegativeInt, PositiveInt + +from hw2.rest_example import store + +from .contracts import ( + PatchPokemonRequest, + PokemonRequest, + PokemonResponse, +) + +router = APIRouter(prefix="/pokemon") + + +@router.get("/") +async def get_pokemon_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, +) -> list[PokemonResponse]: + return [PokemonResponse.from_entity(e) for e in store.get_many(offset, limit)] + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested pokemon", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested pokemon as one was not found", + }, + }, +) +async def get_pokemon_by_id(id: int) -> PokemonResponse: + entity = store.get_one(id) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /pokemon/{id} was not found", + ) + + return PokemonResponse.from_entity(entity) + + +@router.post( + "/", + status_code=HTTPStatus.CREATED, +) +async def post_pokemon(info: PokemonRequest, response: Response) -> PokemonResponse: + entity = store.add(info.as_pokemon_info()) + + # as REST states one should provide uri to newly created resource in location header + response.headers["location"] = f"/pokemon/{entity.id}" + + return PokemonResponse.from_entity(entity) + + +@router.patch( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched pokemon", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify pokemon as one was not found", + }, + }, +) +async def patch_pokemon(id: int, info: PatchPokemonRequest) -> PokemonResponse: + entity = store.patch(id, info.as_patch_pokemon_info()) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /pokemon/{id} was not found", + ) + + return PokemonResponse.from_entity(entity) + + +@router.put( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully updated or upserted pokemon", + }, + HTTPStatus.NOT_MODIFIED: { + "description": "Failed to modify pokemon as one was not found", + }, + } +) +async def put_pokemon( + id: int, + info: PokemonRequest, + upsert: Annotated[bool, Query()] = False, +) -> PokemonResponse: + entity = ( + store.upsert(id, info.as_pokemon_info()) + if upsert + else store.update(id, info.as_pokemon_info()) + ) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_MODIFIED, + f"Requested resource /pokemon/{id} was not found", + ) + + return PokemonResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_pokemon(id: int) -> Response: + store.delete(id) + return Response("") diff --git a/hw4/rest_example/main.py b/hw4/rest_example/main.py new file mode 100644 index 00000000..26a2cf80 --- /dev/null +++ b/hw4/rest_example/main.py @@ -0,0 +1,7 @@ +from fastapi import FastAPI + +from hw2.rest_example.api.pokemon import router + +app = FastAPI(title="Pokemon REST API Example") + +app.include_router(router) diff --git a/hw4/rest_example/requirements.txt b/hw4/rest_example/requirements.txt new file mode 100644 index 00000000..b66bec1e --- /dev/null +++ b/hw4/rest_example/requirements.txt @@ -0,0 +1 @@ +fastapi>=0.117.1 diff --git a/hw4/rest_example/store/__init__.py b/hw4/rest_example/store/__init__.py new file mode 100644 index 00000000..cb99d02a --- /dev/null +++ b/hw4/rest_example/store/__init__.py @@ -0,0 +1,15 @@ +from .models import PatchPokemonInfo, PokemonEntity, PokemonInfo +from .queries import add, delete, get_many, get_one, patch, update, upsert + +__all__ = [ + "PokemonEntity", + "PokemonInfo", + "PatchPokemonInfo", + "add", + "delete", + "get_many", + "get_one", + "update", + "upsert", + "patch", +] diff --git a/hw4/rest_example/store/models.py b/hw4/rest_example/store/models.py new file mode 100644 index 00000000..95cd40b9 --- /dev/null +++ b/hw4/rest_example/store/models.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass + + +@dataclass(slots=True) +class PokemonInfo: + name: str + published: bool + + +@dataclass(slots=True) +class PokemonEntity: + id: int + info: PokemonInfo + + +@dataclass(slots=True) +class PatchPokemonInfo: + name: str | None = None + published: bool | None = None diff --git a/hw4/rest_example/store/queries.py b/hw4/rest_example/store/queries.py new file mode 100644 index 00000000..959492d7 --- /dev/null +++ b/hw4/rest_example/store/queries.py @@ -0,0 +1,75 @@ +from typing import Iterable + +from hw2.rest_example.store.models import ( + PatchPokemonInfo, + PokemonEntity, + PokemonInfo, +) + +_data = dict[int, PokemonInfo]() + + +def int_id_generator() -> Iterable[int]: + i = 0 + while True: + yield i + i += 1 + + +_id_generator = int_id_generator() + + +def add(info: PokemonInfo) -> PokemonEntity: + _id = next(_id_generator) + _data[_id] = info + + return PokemonEntity(_id, info) + + +def delete(id: int) -> None: + if id in _data: + del _data[id] + + +def get_one(id: int) -> PokemonEntity | None: + if id not in _data: + return None + + return PokemonEntity(id=id, info=_data[id]) + + +def get_many(offset: int = 0, limit: int = 10) -> Iterable[PokemonEntity]: + curr = 0 + for id, info in _data.items(): + if offset <= curr < offset + limit: + yield PokemonEntity(id, info) + + curr += 1 + + +def update(id: int, info: PokemonInfo) -> PokemonEntity | None: + if id not in _data: + return None + + _data[id] = info + + return PokemonEntity(id=id, info=info) + + +def upsert(id: int, info: PokemonInfo) -> PokemonEntity: + _data[id] = info + + return PokemonEntity(id=id, info=info) + + +def patch(id: int, patch_info: PatchPokemonInfo) -> PokemonEntity | None: + if id not in _data: + return None + + if patch_info.name is not None: + _data[id].name = patch_info.name + + if patch_info.published is not None: + _data[id].published = patch_info.published + + return PokemonEntity(id=id, info=_data[id]) diff --git a/hw4/ws_example/README.md b/hw4/ws_example/README.md new file mode 100644 index 00000000..471f0d10 --- /dev/null +++ b/hw4/ws_example/README.md @@ -0,0 +1,19 @@ +# WebSocket Example + +Минимально рабочий пример WebSocket broadcaster'а как пример из лекции. Сервер принимает подключения от клиентов и рассылает сообщения всем подключенным одновременно. + +Важная особенность: сервер должен хранить активные WebSocket подключения непосредственно в оперативной памяти, из-за чего эта штука stateful. При перезапуске сервера все соединения потеряются, и горизонтальное масштабирование становится проблематичным без дополнительных решений. + +Для запуска сервера: + +```sh +uvicorn server:app --reload +``` + +Для запуска клиента в отдельном терминале: + +```sh +python client.py +``` + +Можно отправлять сообщения всем подключенным клиентам через POST запрос на `/publish`. diff --git a/hw4/ws_example/__init__.py b/hw4/ws_example/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw4/ws_example/client.py b/hw4/ws_example/client.py new file mode 100644 index 00000000..0af3441b --- /dev/null +++ b/hw4/ws_example/client.py @@ -0,0 +1,6 @@ +from websocket import create_connection + +ws = create_connection("ws://localhost:8000/subscribe") + +while True: + print(ws.recv()) diff --git a/hw4/ws_example/requirements.txt b/hw4/ws_example/requirements.txt new file mode 100644 index 00000000..3af9a713 --- /dev/null +++ b/hw4/ws_example/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.117.1 +websockets>=0.2.1 diff --git a/hw4/ws_example/server.py b/hw4/ws_example/server.py new file mode 100644 index 00000000..2bb5f1d1 --- /dev/null +++ b/hw4/ws_example/server.py @@ -0,0 +1,46 @@ +from dataclasses import dataclass, field +from uuid import uuid4 + +from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect + +app = FastAPI() + + +@dataclass(slots=True) +class Broadcaster: + subscribers: list[WebSocket] = field(init=False, default_factory=list) + + async def subscribe(self, ws: WebSocket) -> None: + await ws.accept() + self.subscribers.append(ws) + + async def unsubscribe(self, ws: WebSocket) -> None: + self.subscribers.remove(ws) + + async def publish(self, message: str) -> None: + for ws in self.subscribers: + await ws.send_text(message) + + +broadcaster = Broadcaster() + + +@app.post("/publish") +async def post_publish(request: Request): + message = (await request.body()).decode() + await broadcaster.publish(message) + + +@app.websocket("/subscribe") +async def ws_subscribe(ws: WebSocket): + client_id = uuid4() + await broadcaster.subscribe(ws) + await broadcaster.publish(f"client {client_id} subscribed") + + try: + while True: + text = await ws.receive_text() + await broadcaster.publish(text) + except WebSocketDisconnect: + broadcaster.unsubscribe(ws) + await broadcaster.publish(f"client {client_id} unsubscribed") From db7047a4b3f83b8013ee89aff5256822fa884aef Mon Sep 17 00:00:00 2001 From: sidoine Date: Mon, 20 Oct 2025 15:34:53 +0300 Subject: [PATCH 05/48] HW5 --- hw5/hw/.githubworkflowsci.yml | 57 +++ hw5/hw/Dockerfile | 33 ++ hw5/hw/README.md | 123 ++++++ hw5/hw/__init__.py | 0 hw5/hw/database.py | 35 ++ hw5/hw/docker-compose.yml | 33 ++ hw5/hw/init_db.py | 12 + hw5/hw/main.py | 13 + hw5/hw/pytest.ini | 6 + hw5/hw/report.txt | 58 +++ hw5/hw/requirements-test.txt | 19 + hw5/hw/shop_api/__init__.py | 0 hw5/hw/shop_api/cart/contracts.py | 19 + hw5/hw/shop_api/cart/routers.py | 101 +++++ hw5/hw/shop_api/cart/store/__init__.py | 13 + hw5/hw/shop_api/cart/store/models.py | 38 ++ hw5/hw/shop_api/cart/store/queries.py | 102 +++++ hw5/hw/shop_api/item/contracts.py | 62 +++ hw5/hw/shop_api/item/routers.py | 127 +++++++ hw5/hw/shop_api/item/store/__init__.py | 14 + hw5/hw/shop_api/item/store/models.py | 27 ++ hw5/hw/shop_api/item/store/queries.py | 57 +++ hw5/hw/shop_api/uvicorn | 0 hw5/hw/test.db | Bin 0 -> 28672 bytes hw5/hw/tests/conftest.py | 40 ++ hw5/hw/tests/pytest | 0 .../test_contracts/test_cart_contracts.py | 35 ++ .../test_contracts/test_edge_contracts.py | 44 +++ .../test_contracts/test_item_contracts.py | 67 ++++ hw5/hw/tests/test_edge_cases.py | 46 +++ hw5/hw/tests/test_final_coverage.py | 56 +++ .../tests/test_integration/test_full_flow.py | 82 ++++ hw5/hw/tests/test_main.py | 14 + hw5/hw/tests/test_models/test_cart_models.py | 35 ++ hw5/hw/tests/test_models/test_item_models.py | 38 ++ .../tests/test_queries/test_cart_queries.py | 104 +++++ .../tests/test_queries/test_item_queries.py | 120 ++++++ .../tests/test_routers/test_cart_routers.py | 97 +++++ .../tests/test_routers/test_item_routers.py | 143 +++++++ hw5/hw/transaction_demo.py | 355 ++++++++++++++++++ 40 files changed, 2225 insertions(+) create mode 100644 hw5/hw/.githubworkflowsci.yml create mode 100644 hw5/hw/Dockerfile create mode 100644 hw5/hw/README.md create mode 100644 hw5/hw/__init__.py create mode 100644 hw5/hw/database.py create mode 100644 hw5/hw/docker-compose.yml create mode 100644 hw5/hw/init_db.py create mode 100644 hw5/hw/main.py create mode 100644 hw5/hw/pytest.ini create mode 100644 hw5/hw/report.txt create mode 100644 hw5/hw/requirements-test.txt create mode 100644 hw5/hw/shop_api/__init__.py create mode 100644 hw5/hw/shop_api/cart/contracts.py create mode 100644 hw5/hw/shop_api/cart/routers.py create mode 100644 hw5/hw/shop_api/cart/store/__init__.py create mode 100644 hw5/hw/shop_api/cart/store/models.py create mode 100644 hw5/hw/shop_api/cart/store/queries.py create mode 100644 hw5/hw/shop_api/item/contracts.py create mode 100644 hw5/hw/shop_api/item/routers.py create mode 100644 hw5/hw/shop_api/item/store/__init__.py create mode 100644 hw5/hw/shop_api/item/store/models.py create mode 100644 hw5/hw/shop_api/item/store/queries.py create mode 100644 hw5/hw/shop_api/uvicorn create mode 100644 hw5/hw/test.db create mode 100644 hw5/hw/tests/conftest.py create mode 100644 hw5/hw/tests/pytest create mode 100644 hw5/hw/tests/test_contracts/test_cart_contracts.py create mode 100644 hw5/hw/tests/test_contracts/test_edge_contracts.py create mode 100644 hw5/hw/tests/test_contracts/test_item_contracts.py create mode 100644 hw5/hw/tests/test_edge_cases.py create mode 100644 hw5/hw/tests/test_final_coverage.py create mode 100644 hw5/hw/tests/test_integration/test_full_flow.py create mode 100644 hw5/hw/tests/test_main.py create mode 100644 hw5/hw/tests/test_models/test_cart_models.py create mode 100644 hw5/hw/tests/test_models/test_item_models.py create mode 100644 hw5/hw/tests/test_queries/test_cart_queries.py create mode 100644 hw5/hw/tests/test_queries/test_item_queries.py create mode 100644 hw5/hw/tests/test_routers/test_cart_routers.py create mode 100644 hw5/hw/tests/test_routers/test_item_routers.py create mode 100644 hw5/hw/transaction_demo.py diff --git a/hw5/hw/.githubworkflowsci.yml b/hw5/hw/.githubworkflowsci.yml new file mode 100644 index 00000000..7226c5e2 --- /dev/null +++ b/hw5/hw/.githubworkflowsci.yml @@ -0,0 +1,57 @@ +name: CI Tests and Coverage + +on: + push: + branches: [ main, master ] + pull_request: + branches: [ main, master ] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: test_db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.12' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements-test.txt + + - name: Run tests with coverage + env: + DATABASE_URL: postgresql://postgres:password@postgres:5432/test_db # ✅ Correction ici + run: | + pytest --cov=shop_api --cov-report=xml --cov-report=term-missing + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml + flags: unittests + fail_ci_if_error: false + + - name: Check 95% coverage threshold + run: | + python -m coverage report --fail-under=95 \ No newline at end of file diff --git a/hw5/hw/Dockerfile b/hw5/hw/Dockerfile new file mode 100644 index 00000000..a11b8927 --- /dev/null +++ b/hw5/hw/Dockerfile @@ -0,0 +1,33 @@ +FROM python:3.12 + +# Variables d'environnement pour optimiser Python +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +# Mettre à jour et installer gcc (nécessaire pour certaines dépendances) +RUN apt-get update && apt-get install -y gcc + +# Mettre à jour pip +RUN python -m pip install --upgrade pip + +# Créer et définir le répertoire de travail +WORKDIR /app + +# Copier d'abord requirements.txt pour optimiser le cache Docker +COPY requirements.txt . + +# Installer les dépendances Python +RUN pip install --no-cache-dir -r requirements.txt + +# Copier le reste de l'application +COPY . . + +# Exposer le port 8080 +EXPOSE 8080 + +# Commande pour lancer l'application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080", "--reload"] \ No newline at end of file diff --git a/hw5/hw/README.md b/hw5/hw/README.md new file mode 100644 index 00000000..ba9f23c8 --- /dev/null +++ b/hw5/hw/README.md @@ -0,0 +1,123 @@ +# ДЗ + +## Задание - REST API (3 балла) + +Реализовать REST + RPC API для выдуманного интернет магазина. + +Тесты завязаны на объект `app = FastAPI(title="Shop API")` из файла [hw2/hw/shop_api/main.py](./shop_api/main.py), поэтому реализовывайте его используя его + +Ресурсы: + +- корзина (cart) + + Пример структуры ресурса: + + ```json + { + "id": 123, // идентификатор корзины + "items": [ // список товаров в корзине + { + "id": 1, // id товара + "name": "Туалетная бумага \"Поцелуй\", рулон", // название + "quantity": 3, // количество товара в корзине + "available": true // доступе ли (не удален ли) товар + }, + { + "id": 535, + "name": "Золотая цепочка \"Abendsonne\"", + "quantity": 1, + "available": false, + }, + ], + "price": 234.4 // общая сумма заказа + } + ``` + +- товар (item) + + Пример структуры ресурса: + + ```json + { + "id": 321, // идентификатор товара + "name": "Молоко \"Буреночка\" 1л.", // наименование товара + "price": 159.99, // цена товара + "deleted": false // удален ли товар, по умолчанию false + } + ``` + +Запросы для реализации: + +- cart + - `POST cart` - создание, работает как RPC, не принимает тело, возвращает + идентификатор + - `GET /cart/{id}` - получение корзины по `id` + - `GET /cart` - получение списка корзин с query-параметрами + - `offset` - неотрицательное целое число, смещение по списку (опционально, + по-умолчанию 0) + - `limit` - положительное целое число, ограничение на количество + (опционально, по-умолчанию 10) + - `min_price` - число с плавающей запятой, минимальная цена включительно + (опционально, если нет, не учитывает в фильтре) + - `max_price` - число с плавающей запятой, максимальная цена включительно + (опционально, если нет, не учитывает в фильтре) + - `min_quantity` - неотрицательное целое число, минимальное общее число + товаров включительно (опционально, если нет, не учитывается в фильтре) + - `max_quantity` - неотрицательное целое число, максимальное общее число + товаров включительно (опционально, если нет, не учитывается в фильтре) + - `POST /cart/{cart_id}/add/{item_id}` - добавление в корзину с `cart_id` + предмета с `item_id`, если товар уже есть, то увеличивается его количество +- item + - `POST /item` - добавление нового товара + - `GET /item/{id}` - получение товара по `id` + - `GET /item` - получение списка товаров с query-параметрами + - `offset` - неотрицательное целое число, смещение по списку (опционально, + по-умолчанию 0) + - `limit` - положительное целое число, ограничение на количество + (опционально, по-умолчанию 10) + - `min_price` - число с плавающей запятой, минимальная цена (опционально, + если нет, не учитывает в фильтре) + - `max_price` - число с плавающей запятой, максимальная цена (опционально, + если нет, не учитывает в фильтре) + - `show_deleted` - булевая переменная, показывать ли удаленные товары (по + умолчанию `False`) + - `PUT /item/{id}` - замена товара по `id` (создание запрещено, только замена + существующего) + - `PATCH /item/{id}` - частичное обновление товара по `id` (разрешено менять + все поля, кроме `deleted`) + - `DELETE /item/{id}` - удаление товара по `id` (товар помечается как + удаленный) + +Способ хранение данных на усмотрение. + +Более подробные детали и требования к работе методов смотрите в тестах. +Модификация тестов при потребности допускается (но не смысловая). + +Чтобы запустить тесты только для этого задания вызовите: + +```sh +pytest -vv --showlocals --strict ./hw2/test_homework_2_1.py +``` + +Если получаете ошибку на подобии `No module named 'shop_api'` +Понадобится еще такая команда, после которой можно запускать тесты: + +```sh +export PYTHONPATH=${PWD}/hw2/hw +``` + +## Доп. Задание - WebSocket (+ доп балл) + +Реализовать чат для пользователей в отдельных комнатах (в примере один на всех). + +Пользователи подключаются к чату по WebSocket ручке `/chat/{chat_name}`. +Пользователи, которые ввели один и тот же `chat_name` буду подключены к одному +чату (то есть будут получать сообщения друг от друга). Пользователи не +подключенные к диалогу не будут получать сообщения. + +Сообщение - текст в теле сообщения от клиента. Сервер должен broadcast'ить +сообщения на других пользователей в своем чате. Каждому клиенту сервер +присваивает случайное имя и дополняет каждое сообщение именем пользователя в +начале в следующем виде: `{username} :: {message}`. + +Если делаете его, напишите, пожалуйста, прямо в PR-e об этом. Мне будет сильно проще это заметить<3 diff --git a/hw5/hw/__init__.py b/hw5/hw/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5/hw/database.py b/hw5/hw/database.py new file mode 100644 index 00000000..baf17d19 --- /dev/null +++ b/hw5/hw/database.py @@ -0,0 +1,35 @@ +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +#from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import declarative_base + +import os + +# URL de connexion à PostgreSQL +# Dans ton conftest.py ou database.py + +if os.getenv("TESTING"): + # Pour les tests - SQLite en mémoire (rapide et isolé) + DATABASE_URL = "sqlite:///:memory:" + # ou + DATABASE_URL = "sqlite:///./test.db" +else: + # Pour le développement - utilise ton URL normale + DATABASE_URL = "postgresql://postgres:password@postgres:5432/shop_db" + +# Moteur de connexion +engine = create_engine(DATABASE_URL) + +# Session pour interagir avec la base +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +# Base pour tous nos modèles +Base = declarative_base() + +# Fonction pour obtenir une session (utilisée dans les routes) +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() \ No newline at end of file diff --git a/hw5/hw/docker-compose.yml b/hw5/hw/docker-compose.yml new file mode 100644 index 00000000..4d479fce --- /dev/null +++ b/hw5/hw/docker-compose.yml @@ -0,0 +1,33 @@ + +services: + postgres: + image: postgres:15 + container_name: shop_postgres + environment: + POSTGRES_DB: shop_db + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + api: + build: . + ports: + - "8080:8080" + environment: + - DATABASE_URL=postgresql://postgres:password@postgres:5432/shop_db + depends_on: + postgres: + condition: service_healthy + volumes: + - .:/app + +volumes: + postgres_data: \ No newline at end of file diff --git a/hw5/hw/init_db.py b/hw5/hw/init_db.py new file mode 100644 index 00000000..73daefc8 --- /dev/null +++ b/hw5/hw/init_db.py @@ -0,0 +1,12 @@ +from database import engine, Base +from shop_api.item.store.models import ItemDB +from shop_api.cart.store.models import CartDB, CartItemDB + +def create_tables(): + print("Création des tables dans la base de données...") + Base.metadata.create_all(bind=engine) + print("✅ Tables créées avec succès!") + print("📊 Tables créées : items, carts, cart_items") + +if __name__ == "__main__": + create_tables() \ No newline at end of file diff --git a/hw5/hw/main.py b/hw5/hw/main.py new file mode 100644 index 00000000..ac75017c --- /dev/null +++ b/hw5/hw/main.py @@ -0,0 +1,13 @@ +from fastapi import FastAPI + +from shop_api.cart.routers import router as cart +from shop_api.item.routers import router as item + +app = FastAPI(title="Shop API") + +app.include_router(cart) +app.include_router(item) + +@app.get("/") +async def root(): + return {"message": "API Shop is running"} \ No newline at end of file diff --git a/hw5/hw/pytest.ini b/hw5/hw/pytest.ini new file mode 100644 index 00000000..11d9c572 --- /dev/null +++ b/hw5/hw/pytest.ini @@ -0,0 +1,6 @@ +[tool:pytest] +testpaths = tests +addopts = -v --cov=shop_api --cov-report=term-missing --cov-report=html +python_files = test_*.py +python_classes = Test* +python_functions = test_* \ No newline at end of file diff --git a/hw5/hw/report.txt b/hw5/hw/report.txt new file mode 100644 index 00000000..ee0ceabe --- /dev/null +++ b/hw5/hw/report.txt @@ -0,0 +1,58 @@ +Тестовые данные созданы с использованием существующих моделей + +================================================== +DIRTY READ - READ UNCOMMITTED +================================================== +Транзакция 1: Изменяю цену TestItem1 +50€ (без коммита) +Транзакция 1: Изменил цену с 100.0 на 150.0 +Транзакция 2: Читаю цену TestItem1... +Транзакция 2: Вижу 100.0€ -> DIRTY READ! +Транзакция 1: Делаю rollback! + +================================================== +НЕТ DIRTY READ - READ COMMITTED +================================================== +Транзакция 1: Изменяю цену TestItem1 +50€ (без коммита) +Транзакция 1: Изменил цену с 100.0 на 150.0 +Транзакция 2: Читаю цену TestItem1... +Транзакция 2: Вижу 100.0€ -> Чистые данные! +Транзакция 1: Делаю rollback! + +================================================== +NON-REPEATABLE READ - READ COMMITTED +================================================== +Транзакция 2: Первое чтение -> 200.0€ +Транзакция 1: Изменяю TestItem2 +100€ и коммит +Транзакция 1: Изменил цену с 200.0 на 300.0 и закоммитил +Транзакция 2: Второе чтение -> 300.0€ +NON-REPEATABLE READ обнаружен! + +================================================== +НЕТ NON-REPEATABLE READ - REPEATABLE READ +================================================== +Транзакция 2: Первое чтение -> 300.0€ +Транзакция 1: Изменяю TestItem2 +150€ и коммит +Транзакция 1: Изменил цену с 300.0 на 450.0 и закоммитил +Транзакция 2: Второе чтение -> 300.0€ +Нет Non-Repeatable Read с REPEATABLE READ! + +================================================== +PHANTOM READ - REPEATABLE READ +================================================== +Транзакция 2: Первый подсчет -> 5 товаров +Транзакция 1: Добавляю новый товар 'PhantomItem' +Транзакция 1: Новый товар добавлен и закоммичен! +Транзакция 2: Второй подсчет -> 5 товаров + +================================================== +НЕТ PHANTOM READ - SERIALIZABLE +================================================== +Транзакция 2: Первый подсчет -> 6 товаров +Транзакция 1: Добавляю новый товар 'SerializableItem' +Транзакция 1: Новый товар добавлен и закоммичен! +Транзакция 2: Второй подсчет -> 6 товаров +Нет Phantom Read с SERIALIZABLE! + +================================================== +ДЕМОНСТРАЦИЯ ЗАВЕРШЕНА! +================================================== \ No newline at end of file diff --git a/hw5/hw/requirements-test.txt b/hw5/hw/requirements-test.txt new file mode 100644 index 00000000..8bf265ba --- /dev/null +++ b/hw5/hw/requirements-test.txt @@ -0,0 +1,19 @@ +# ===== DÉPENDANCES PRINCIPALES ===== +fastapi>=0.104.0 +uvicorn>=0.24.0 +starlette>=0.27.0 +sqlalchemy>=2.0.0 +psycopg2-binary>=2.9.0 +alembic>=1.13.0 +pydantic>=2.5.0 +python-dotenv>=1.0.0 + +# ===== DÉPENDANCES DE TESTS ===== +pytest>=7.4.0 +pytest-cov>=4.1.0 +pytest-mock>=3.12.0 +pytest-asyncio>=0.21.0 +httpx>=0.25.0 +requests>=2.31.0 +responses>=0.24.1 +faker>=19.0.0 \ No newline at end of file diff --git a/hw5/hw/shop_api/__init__.py b/hw5/hw/shop_api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/hw5/hw/shop_api/cart/contracts.py b/hw5/hw/shop_api/cart/contracts.py new file mode 100644 index 00000000..c052cee3 --- /dev/null +++ b/hw5/hw/shop_api/cart/contracts.py @@ -0,0 +1,19 @@ +from __future__ import annotations +from pydantic import BaseModel +from shop_api.cart.store.models import CartEntity, CartInfo, CartItemInfo + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + +class CartResponse(BaseModel): + id: int + items: list[CartItemResponse] + price: float + + @staticmethod + def from_entity(entity: CartEntity) -> CartResponse: + items = [CartItemResponse(id=item.id, name=item.name, quantity=item.quantity, available=item.available) for item in entity.info.items] + return CartResponse(id=entity.id, items=items, price=entity.info.price) \ No newline at end of file diff --git a/hw5/hw/shop_api/cart/routers.py b/hw5/hw/shop_api/cart/routers.py new file mode 100644 index 00000000..612ef4db --- /dev/null +++ b/hw5/hw/shop_api/cart/routers.py @@ -0,0 +1,101 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response, Depends +from sqlalchemy.orm import Session +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + +from shop_api.cart import store +from shop_api.cart.contracts import CartResponse +import shop_api.item.store +from database import get_db + +router = APIRouter(prefix="/cart") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_cart( + response: Response, + db: Session = Depends(get_db) +) -> CartResponse: + entity = store.create(db) + response.headers["location"] = f"/cart/{entity.id}" + return CartResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested cart", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested cart as one was not found", + }, + }, +) +async def get_cart_by_id( + id: int, + db: Session = Depends(get_db) +): + entity = store.get_one(id, db) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /cart/{id} was not found", + ) + + return CartResponse.from_entity(entity) + + +@router.get("/") +async def get_cart_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + min_quantity: Annotated[NonNegativeFloat, Query()] | None = None, + max_quantity: Annotated[NonNegativeFloat, Query()] | None = None, + db: Session = Depends(get_db) +): + return [ + CartResponse.from_entity(e) + for e in store.get_many(db, offset, limit, min_price, max_price, min_quantity, max_quantity) + ] + + +@router.post("/{cart_id}/add/{item_id}") +async def add_to_cart( + cart_id: int, + item_id: int, + db: Session = Depends(get_db) +): + # Récupère l'item depuis la base + item_entity = shop_api.item.store.get_one(item_id, db) + + if not item_entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Item {item_id} not found", + ) + + # Ajoute l'item au panier + entity = store.add(cart_id, item_entity, db) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Cart {cart_id} not found", + ) + + return CartResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_cart( + id: int, + db: Session = Depends(get_db) +) -> Response: + store.delete(id, db) + return Response("", status_code=HTTPStatus.NO_CONTENT) \ No newline at end of file diff --git a/hw5/hw/shop_api/cart/store/__init__.py b/hw5/hw/shop_api/cart/store/__init__.py new file mode 100644 index 00000000..e14c47c2 --- /dev/null +++ b/hw5/hw/shop_api/cart/store/__init__.py @@ -0,0 +1,13 @@ +from .models import CartItemInfo, CartEntity, CartInfo +from .queries import add, delete, get_many, get_one, create + +__all__ = [ + "CartEntity", + "CartInfo", + "CartItemInfo", + "add", + "delete", + "get_many", + "get_one", + "create", +] diff --git a/hw5/hw/shop_api/cart/store/models.py b/hw5/hw/shop_api/cart/store/models.py new file mode 100644 index 00000000..9d55e963 --- /dev/null +++ b/hw5/hw/shop_api/cart/store/models.py @@ -0,0 +1,38 @@ +from sqlalchemy import Column, Integer, ForeignKey, DateTime +from sqlalchemy.orm import relationship +from sqlalchemy.sql import func +from database import Base + +class CartDB(Base): + __tablename__ = "carts" + id = Column(Integer, primary_key=True, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + items = relationship("CartItemDB", back_populates="cart") + +class CartItemDB(Base): + __tablename__ = "cart_items" + id = Column(Integer, primary_key=True, index=True) + cart_id = Column(Integer, ForeignKey("carts.id")) + item_id = Column(Integer, ForeignKey("items.id")) + quantity = Column(Integer, default=1) + cart = relationship("CartDB", back_populates="items") + item = relationship("ItemDB") + +from dataclasses import dataclass + +@dataclass(slots=True) +class CartItemInfo: + id: int + name: str + quantity: int + available: bool + +@dataclass(slots=True) +class CartInfo: + items: list[CartItemInfo] + price: float + +@dataclass(slots=True) +class CartEntity: + id: int + info: CartInfo \ No newline at end of file diff --git a/hw5/hw/shop_api/cart/store/queries.py b/hw5/hw/shop_api/cart/store/queries.py new file mode 100644 index 00000000..54dc56f3 --- /dev/null +++ b/hw5/hw/shop_api/cart/store/queries.py @@ -0,0 +1,102 @@ +from sqlalchemy.orm import Session +from typing import Iterable +from .models import CartDB, CartItemDB, CartEntity, CartInfo, CartItemInfo +from shop_api.item.store.models import ItemDB + +def create(db: Session) -> CartEntity: + db_cart = CartDB() + db.add(db_cart) + db.commit() + db.refresh(db_cart) + return CartEntity(id=db_cart.id, info=CartInfo(items=[], price=0.0)) + +def add(cart_id: int, item_entity, db: Session) -> CartEntity: + db_cart = db.query(CartDB).filter(CartDB.id == cart_id).first() + if not db_cart: + return None + + # Vérifie que l'item existe en base + db_item = db.query(ItemDB).filter(ItemDB.id == item_entity.id).first() + if not db_item: + return None + + existing_item = db.query(CartItemDB).filter( + CartItemDB.cart_id == cart_id, + CartItemDB.item_id == item_entity.id + ).first() + + if existing_item: + existing_item.quantity += 1 + else: + new_item = CartItemDB(cart_id=cart_id, item_id=item_entity.id, quantity=1) + db.add(new_item) + + db.commit() + return get_one(cart_id, db) + +def delete(id: int, db: Session) -> None: + db_cart = db.query(CartDB).filter(CartDB.id == id).first() + if db_cart: + # Supprime d'abord les items du panier + db.query(CartItemDB).filter(CartItemDB.cart_id == id).delete() + db.delete(db_cart) + db.commit() + +def get_one(id: int, db: Session) -> CartEntity | None: + from shop_api.item.store.models import ItemDB + db_cart = db.query(CartDB).filter(CartDB.id == id).first() + if not db_cart: + return None + + total_price = 0.0 + cart_items = [] + + for cart_item in db_cart.items: + item = cart_item.item + if item: # Vérification de sécurité + item_total = item.price * cart_item.quantity + total_price += item_total + cart_items.append(CartItemInfo( + id=item.id, + name=item.name, + quantity=cart_item.quantity, + available=not item.deleted + )) + + return CartEntity(id=db_cart.id, info=CartInfo(items=cart_items, price=total_price)) + +def get_many(db: Session, offset: int = 0, limit: int = 10, + min_price: float = None, max_price: float = None, + min_quantity: int = None, max_quantity: int = None) -> Iterable[CartEntity]: + + query = db.query(CartDB) + + # Applique les filtres au niveau de la requête SQL pour plus d'efficacité + if min_price is not None or max_price is not None: + # Sous-requête pour les paniers avec prix dans la plage + from sqlalchemy import func + subquery = db.query(CartItemDB.cart_id).join(ItemDB).group_by(CartItemDB.cart_id) + + if min_price is not None: + subquery = subquery.having(func.sum(ItemDB.price * CartItemDB.quantity) >= min_price) + if max_price is not None: + subquery = subquery.having(func.sum(ItemDB.price * CartItemDB.quantity) <= max_price) + + query = query.filter(CartDB.id.in_(subquery)) + + db_carts = query.offset(offset).limit(limit).all() + + for db_cart in db_carts: + cart_entity = get_one(db_cart.id, db) + if not cart_entity: + continue + + # Filtres supplémentaires au niveau application + total_quantity = sum(item.quantity for item in cart_entity.info.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 + + yield cart_entity \ No newline at end of file diff --git a/hw5/hw/shop_api/item/contracts.py b/hw5/hw/shop_api/item/contracts.py new file mode 100644 index 00000000..a1791bc8 --- /dev/null +++ b/hw5/hw/shop_api/item/contracts.py @@ -0,0 +1,62 @@ +from __future__ import annotations +from pydantic import BaseModel, ConfigDict, field_validator +from shop_api.item.store.models import ItemEntity, ItemInfo, PatchItemInfo + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool + + @staticmethod + def from_entity(entity: ItemEntity) -> ItemResponse: + return ItemResponse( + id=entity.id, + name=entity.info.name, + price=entity.info.price, + deleted=entity.info.deleted + ) + +class ItemRequest(BaseModel): + name: str + price: float + deleted: bool = False + + @field_validator('price') + @classmethod + def validate_price(cls, v): + if v < 0: + raise ValueError('Le prix ne peut pas être négatif') + return v + + @field_validator('name') + @classmethod + def validate_name(cls, v): + if not v or not v.strip(): + raise ValueError('Le nom ne peut pas être vide') + return v.strip() + + def as_item_info(self) -> ItemInfo: + return ItemInfo(name=self.name, price=self.price, deleted=self.deleted) + +class PatchItemRequest(BaseModel): + name: str | None = None + price: float | None = None + model_config = ConfigDict(extra="forbid") + + @field_validator('price') + @classmethod + def validate_price(cls, v): + if v is not None and v < 0: + raise ValueError('Le prix ne peut pas être négatif') + return v + + @field_validator('name') + @classmethod + def validate_name(cls, v): + if v is not None and (not v or not v.strip()): + raise ValueError('Le nom ne peut pas être vide') + return v.strip() if v else v + + def as_patch_item_info(self) -> PatchItemInfo: + return PatchItemInfo(name=self.name, price=self.price) \ No newline at end of file diff --git a/hw5/hw/shop_api/item/routers.py b/hw5/hw/shop_api/item/routers.py new file mode 100644 index 00000000..fc237136 --- /dev/null +++ b/hw5/hw/shop_api/item/routers.py @@ -0,0 +1,127 @@ +from http import HTTPStatus +from typing import Annotated + +from fastapi import APIRouter, HTTPException, Query, Response, Depends +from sqlalchemy.orm import Session +from pydantic import NonNegativeFloat, NonNegativeInt, PositiveInt + +from shop_api.item import store +from shop_api.item.contracts import ItemRequest, ItemResponse, PatchItemRequest +from database import get_db + +router = APIRouter(prefix="/item") + + +@router.post("/", status_code=HTTPStatus.CREATED) +async def post_item( + info: ItemRequest, + response: Response, + db: Session = Depends(get_db) +) -> ItemResponse: + entity = store.add(info.as_item_info(), db) + response.headers["location"] = f"/item/{entity.id}" + return ItemResponse.from_entity(entity) + + +@router.get( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully returned requested item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to return requested item as one was not found", + }, + }, +) +async def get_item_by_id( + id: int, + db: Session = Depends(get_db) +): + entity = store.get_one(id, db) + + if not entity: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Request resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.get("/") +async def get_item_list( + offset: Annotated[NonNegativeInt, Query()] = 0, + limit: Annotated[PositiveInt, Query()] = 10, + min_price: Annotated[NonNegativeFloat, Query()] | None = None, + max_price: Annotated[NonNegativeFloat, Query()] | None = None, + show_deleted: Annotated[bool, Query()] = False, + db: Session = Depends(get_db) +): + return [ + ItemResponse.from_entity(e) + for e in store.get_many(db, offset, limit, min_price, max_price, show_deleted) + ] + + +@router.patch( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully patched item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def patch_item( + id: int, + info: PatchItemRequest, + db: Session = Depends(get_db) +) -> ItemResponse: + entity = store.patch(id, info.as_patch_item_info(), db) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.put( + "/{id}", + responses={ + HTTPStatus.OK: { + "description": "Successfully updated or upserted item", + }, + HTTPStatus.NOT_FOUND: { + "description": "Failed to modify item as one was not found", + }, + }, +) +async def put_item( + id: int, + info: ItemRequest, + db: Session = Depends(get_db) +) -> ItemResponse: + entity = store.update(id, info.as_item_info(), db) + + if entity is None: + raise HTTPException( + HTTPStatus.NOT_FOUND, + f"Requested resource /item/{id} was not found", + ) + + return ItemResponse.from_entity(entity) + + +@router.delete("/{id}") +async def delete_item( + id: int, + db: Session = Depends(get_db) +) -> Response: + store.delete(id, db) + return Response("", status_code=HTTPStatus.NO_CONTENT) \ No newline at end of file diff --git a/hw5/hw/shop_api/item/store/__init__.py b/hw5/hw/shop_api/item/store/__init__.py new file mode 100644 index 00000000..bf69aa56 --- /dev/null +++ b/hw5/hw/shop_api/item/store/__init__.py @@ -0,0 +1,14 @@ +from .models import PatchItemInfo, ItemEntity, ItemInfo +from .queries import add, delete, get_many, get_one, patch, update + +__all__ = [ + "ItemEntity", + "ItemInfo", + "PatchItemInfo", + "add", + "delete", + "get_many", + "get_one", + "update", + "patch", +] diff --git a/hw5/hw/shop_api/item/store/models.py b/hw5/hw/shop_api/item/store/models.py new file mode 100644 index 00000000..1ae1f37e --- /dev/null +++ b/hw5/hw/shop_api/item/store/models.py @@ -0,0 +1,27 @@ +from sqlalchemy import Column, Integer, String, Float, Boolean +from database import Base + +class ItemDB(Base): + __tablename__ = "items" + id = Column(Integer, primary_key=True, index=True) + name = Column(String(100), nullable=False) + price = Column(Float, nullable=False) + deleted = Column(Boolean, default=False) + +from dataclasses import dataclass + +@dataclass(slots=True) +class ItemInfo: + name: str + price: float + deleted: bool + +@dataclass(slots=True) +class ItemEntity: + id: int + info: ItemInfo + +@dataclass(slots=True) +class PatchItemInfo: + name: str | None = None + price: float | None = None \ No newline at end of file diff --git a/hw5/hw/shop_api/item/store/queries.py b/hw5/hw/shop_api/item/store/queries.py new file mode 100644 index 00000000..180dfb51 --- /dev/null +++ b/hw5/hw/shop_api/item/store/queries.py @@ -0,0 +1,57 @@ +from sqlalchemy.orm import Session +from typing import Iterable +from .models import ItemDB, ItemEntity, ItemInfo, PatchItemInfo + +def add(info: ItemInfo, db: Session) -> ItemEntity: + db_item = ItemDB(name=info.name, price=info.price, deleted=info.deleted) + db.add(db_item) + db.commit() + db.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo(name=db_item.name, price=db_item.price, deleted=db_item.deleted)) + +def delete(id: int, db: Session) -> None: + db_item = db.query(ItemDB).filter(ItemDB.id == id).first() + if db_item: + db.delete(db_item) + db.commit() + +def get_one(id: int, db: Session) -> ItemEntity | None: + db_item = db.query(ItemDB).filter(ItemDB.id == id).first() + if not db_item: + return None + return ItemEntity(id=db_item.id, info=ItemInfo(name=db_item.name, price=db_item.price, deleted=db_item.deleted)) + +def get_many(db: Session, offset: int = 0, limit: int = 10, min_price: float = None, max_price: float = None, show_deleted: bool = False) -> Iterable[ItemEntity]: + query = db.query(ItemDB) + if min_price is not None: + query = query.filter(ItemDB.price >= min_price) + if max_price is not None: + query = query.filter(ItemDB.price <= max_price) + if not show_deleted: + query = query.filter(ItemDB.deleted == False) + db_items = query.offset(offset).limit(limit).all() + for db_item in db_items: + yield ItemEntity(id=db_item.id, info=ItemInfo(name=db_item.name, price=db_item.price, deleted=db_item.deleted)) + +def update(id: int, info: ItemInfo, db: Session) -> ItemEntity | None: + db_item = db.query(ItemDB).filter(ItemDB.id == id).first() + if not db_item: + return None + db_item.name = info.name + db_item.price = info.price + db_item.deleted = info.deleted + db.commit() + db.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo(name=db_item.name, price=db_item.price, deleted=db_item.deleted)) + +def patch(id: int, patch_info: PatchItemInfo, db: Session) -> ItemEntity | None: + db_item = db.query(ItemDB).filter(ItemDB.id == id).first() + if not db_item: + return None + if patch_info.name is not None: + db_item.name = patch_info.name + if patch_info.price is not None: + db_item.price = patch_info.price + db.commit() + db.refresh(db_item) + return ItemEntity(id=db_item.id, info=ItemInfo(name=db_item.name, price=db_item.price, deleted=db_item.deleted)) \ No newline at end of file diff --git a/hw5/hw/shop_api/uvicorn b/hw5/hw/shop_api/uvicorn new file mode 100644 index 00000000..e69de29b diff --git a/hw5/hw/test.db b/hw5/hw/test.db new file mode 100644 index 0000000000000000000000000000000000000000..cd97277f80825fe01c3a79ad3979ac4b8fe58358 GIT binary patch literal 28672 zcmeI)%L&3j5C-6hPsEs5LMreO8?b;}#0oqpco0!M*_w@5iDx%pjwwL-27Y#6A1q%1 zv)eYO`=L5sZs+~ITE{Z7EH+gYF@HwH^qWb^Fe^sniS)#JKo4=f|fx~z+) zX!3Xd?!RM)009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U wAV7cs0RjXF{H(w@{XEV1k9KWm{0~zGDT)9A0t5&UAV7cs0RjXF5cmUu4^8t7&;S4c literal 0 HcmV?d00001 diff --git a/hw5/hw/tests/conftest.py b/hw5/hw/tests/conftest.py new file mode 100644 index 00000000..5ae7448e --- /dev/null +++ b/hw5/hw/tests/conftest.py @@ -0,0 +1,40 @@ +import pytest +import sys +sys.path.append('/app') + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +# SOLUTION : Utilise TestClient depuis fastapi, PAS starlette +from fastapi.testclient import TestClient + +from database import Base, get_db +from main import app + +# Base de données de test +TEST_DATABASE_URL = "sqlite:///./test.db" +engine = create_engine(TEST_DATABASE_URL) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +@pytest.fixture(scope="function") +def db_session(): + Base.metadata.create_all(bind=engine) + session = TestingSessionLocal() + try: + yield session + finally: + session.close() + Base.metadata.drop_all(bind=engine) + +@pytest.fixture(scope="function") +def client(db_session): + def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + + # CORRECTION : Simple création sans problème de version + client = TestClient(app) + yield client + + app.dependency_overrides.clear() \ No newline at end of file diff --git a/hw5/hw/tests/pytest b/hw5/hw/tests/pytest new file mode 100644 index 00000000..e69de29b diff --git a/hw5/hw/tests/test_contracts/test_cart_contracts.py b/hw5/hw/tests/test_contracts/test_cart_contracts.py new file mode 100644 index 00000000..b97082a6 --- /dev/null +++ b/hw5/hw/tests/test_contracts/test_cart_contracts.py @@ -0,0 +1,35 @@ +import pytest +from shop_api.cart.contracts import CartItemResponse, CartResponse +from shop_api.cart.store.models import CartEntity, CartInfo, CartItemInfo + +class TestCartContracts: + def test_cart_item_response(self): + """Test CartItemResponse""" + response = CartItemResponse( + id=1, + name="Test Item", + quantity=2, + available=True + ) + + assert response.id == 1 + assert response.name == "Test Item" + assert response.quantity == 2 + assert response.available is True + + def test_cart_response_from_entity(self): + """Test la conversion d'Entity vers Response""" + items = [ + CartItemInfo(id=1, name="Item1", quantity=1, available=True), + CartItemInfo(id=2, name="Item2", quantity=3, available=False) + ] + entity = CartEntity(id=1, info=CartInfo(items=items, price=400.0)) + + response = CartResponse.from_entity(entity) + + assert response.id == 1 + assert response.price == 400.0 + assert len(response.items) == 2 + assert response.items[0].name == "Item1" + assert response.items[1].quantity == 3 + assert response.items[1].available is False \ No newline at end of file diff --git a/hw5/hw/tests/test_contracts/test_edge_contracts.py b/hw5/hw/tests/test_contracts/test_edge_contracts.py new file mode 100644 index 00000000..106044a6 --- /dev/null +++ b/hw5/hw/tests/test_contracts/test_edge_contracts.py @@ -0,0 +1,44 @@ +import pytest +from pydantic import ValidationError +from shop_api.item.contracts import ItemRequest, PatchItemRequest + +class TestEdgeContracts: + def test_item_request_edge_cases(self): + """Test des cas limites pour ItemRequest""" + # Prix à 0 (devroit être valide) + request = ItemRequest(name="Free Item", price=0.0) + assert request.price == 0.0 + + # Prix très élevé + request = ItemRequest(name="Expensive", price=999999.99) + assert request.price == 999999.99 + + # Nom avec espaces (devrait être strippé par le validateur) + request = ItemRequest(name=" Test Item ", price=10.0) + assert request.name == "Test Item" + + def test_patch_item_request_edge_cases(self): + """Test des cas limites pour PatchItemRequest""" + # Tous les champs None + patch = PatchItemRequest() + assert patch.name is None + assert patch.price is None + + # Prix à 0 + patch = PatchItemRequest(price=0.0) + assert patch.price == 0.0 + + # Nom vide (devrait être validé) + with pytest.raises(ValueError): + PatchItemRequest(name="") + + def test_item_request_boundary_values(self): + """Test des valeurs aux limites""" + # Prix limite (0) + request = ItemRequest(name="Test", price=0.0) + assert request.price == 0.0 + + # Nom très long + long_name = "A" * 100 + request = ItemRequest(name=long_name, price=10.0) + assert request.name == long_name \ No newline at end of file diff --git a/hw5/hw/tests/test_contracts/test_item_contracts.py b/hw5/hw/tests/test_contracts/test_item_contracts.py new file mode 100644 index 00000000..3b7063fa --- /dev/null +++ b/hw5/hw/tests/test_contracts/test_item_contracts.py @@ -0,0 +1,67 @@ +import pytest +from pydantic import ValidationError +from shop_api.item.contracts import ItemRequest, ItemResponse, PatchItemRequest +from shop_api.item.store.models import ItemEntity, ItemInfo + +class TestItemContracts: + def test_item_request_valid(self): + """Test ItemRequest valide""" + request = ItemRequest(name="Test Item", price=100.0) + + assert request.name == "Test Item" + assert request.price == 100.0 + assert request.deleted is False # Valeur par défaut + + def test_item_request_invalid(self): + + with pytest.raises(ValueError): + ItemRequest(name="Test", price=-10.0) + + # Nom vide + with pytest.raises(ValueError): + ItemRequest(name="", price=10.0) + + # Données manquantes - Pydantic lève ValidationError + with pytest.raises(ValidationError): + ItemRequest(name="Test") # price manquant + + + def test_item_response_from_entity(self): + """Test la conversion d'Entity vers Response""" + entity = ItemEntity( + id=1, + info=ItemInfo(name="Test", price=50.0, deleted=False) + ) + + response = ItemResponse.from_entity(entity) + + assert response.id == 1 + assert response.name == "Test" + assert response.price == 50.0 + assert response.deleted is False + + def test_item_request_as_item_info(self): + """Test la conversion vers ItemInfo""" + request = ItemRequest(name="Test", price=75.0, deleted=True) + info = request.as_item_info() + + assert info.name == "Test" + assert info.price == 75.0 + assert info.deleted is True + + def test_patch_item_request(self): + """Test PatchItemRequest""" + # Partiel + patch = PatchItemRequest(name="Nouveau nom") + assert patch.name == "Nouveau nom" + assert patch.price is None + + # Conversion vers PatchItemInfo + patch_info = patch.as_patch_item_info() + assert patch_info.name == "Nouveau nom" + assert patch_info.price is None + + def test_patch_item_request_extra_fields(self): + """Test que les champs supplémentaires sont interdits""" + with pytest.raises(ValidationError): + PatchItemRequest(name="Test", invalid_field="value") \ No newline at end of file diff --git a/hw5/hw/tests/test_edge_cases.py b/hw5/hw/tests/test_edge_cases.py new file mode 100644 index 00000000..595e9849 --- /dev/null +++ b/hw5/hw/tests/test_edge_cases.py @@ -0,0 +1,46 @@ +import pytest +from shop_api.cart.store import queries as cart_queries +from shop_api.item.store import queries as item_queries +from shop_api.item.store.models import ItemInfo, ItemEntity, ItemDB + +class TestEdgeCases: + def test_add_item_to_nonexistent_cart(self, db_session): + """Test ajout d'item à un panier qui n'existe pas""" + # Créer un item valide + item_info = ItemInfo(name="Test", price=10.0, deleted=False) + db_item = ItemDB(name=item_info.name, price=item_info.price, deleted=item_info.deleted) + db_session.add(db_item) + db_session.commit() + db_session.refresh(db_item) + + item_entity = ItemEntity(id=db_item.id, info=item_info) + + # Essayer d'ajouter à un panier inexistant + result = cart_queries.add(999999, item_entity, db_session) + assert result is None # Devrait retourner None + + def test_get_many_carts_edge_cases(self, db_session): + """Test get_many avec des filtres extrêmes""" + # Filtres qui ne matchent rien + carts = list(cart_queries.get_many(db_session, min_price=1000000)) + assert len(carts) == 0 + + # Filtres avec valeurs None + carts = list(cart_queries.get_many(db_session, min_price=None, max_price=None)) + assert isinstance(carts, list) + + def test_cart_queries_none_handling(self, db_session): + """Test gestion des valeurs None dans les queries""" + # get_one avec ID None + result = cart_queries.get_one(None, db_session) + assert result is None + + def test_add_nonexistent_item_to_cart(self, db_session): + """Test ajout d'un item qui n'existe pas en base""" + cart = cart_queries.create(db_session) + + # Créer une entité item avec un ID qui n'existe pas en base + fake_item_entity = ItemEntity(id=999999, info=ItemInfo(name="Fake", price=10.0, deleted=False)) + + result = cart_queries.add(cart.id, fake_item_entity, db_session) + assert result is None \ No newline at end of file diff --git a/hw5/hw/tests/test_final_coverage.py b/hw5/hw/tests/test_final_coverage.py new file mode 100644 index 00000000..601426c9 --- /dev/null +++ b/hw5/hw/tests/test_final_coverage.py @@ -0,0 +1,56 @@ +import pytest +from shop_api.cart.store import queries as cart_queries +from shop_api.item.store.models import ItemInfo, ItemEntity, ItemDB +from shop_api.item.contracts import PatchItemRequest + +class TestFinalCoverage: + def test_get_many_carts_complex_filters(self, db_session): + """Test get_many avec tous les types de filtres""" + # Créer des paniers avec différentes quantités + for i in range(3): + cart = cart_queries.create(db_session) + if i > 0: + # Créer et ajouter des items + item_info = ItemInfo(name=f"Item{i}", price=10.0 * i, deleted=False) + db_item = ItemDB(name=item_info.name, price=item_info.price, deleted=item_info.deleted) + db_session.add(db_item) + db_session.commit() + db_session.refresh(db_item) + item_entity = ItemEntity(id=db_item.id, info=item_info) + cart_queries.add(cart.id, item_entity, db_session) + + # Tester avec min_quantity et max_quantity + carts_min = list(cart_queries.get_many(db_session, min_quantity=1)) + carts_max = list(cart_queries.get_many(db_session, max_quantity=0)) + carts_both = list(cart_queries.get_many(db_session, min_quantity=1, max_quantity=10)) + + assert isinstance(carts_min, list) + assert isinstance(carts_max, list) + assert isinstance(carts_both, list) + + def test_patch_item_request_all_none(self, db_session): + """Test PatchItemRequest avec tous les champs None""" + patch = PatchItemRequest(name=None, price=None) + patch_info = patch.as_patch_item_info() + + assert patch_info.name is None + assert patch_info.price is None + + def test_cart_queries_return_none_cases(self, db_session): + """Test les cas où les queries retournent None""" + # Test avec des IDs négatifs + result = cart_queries.get_one(-1, db_session) + assert result is None + + # Test delete avec ID négatif + cart_queries.delete(-1, db_session) # Ne devrait pas crasher + + def test_item_contracts_edge_validators(self): + """Test des validateurs edge dans les contracts""" + # Test du validateur de prix dans PatchItemRequest + patch = PatchItemRequest(price=0.0) + assert patch.price == 0.0 + + # Test du validateur de nom + patch = PatchItemRequest(name="Valid Name") + assert patch.name == "Valid Name" \ No newline at end of file diff --git a/hw5/hw/tests/test_integration/test_full_flow.py b/hw5/hw/tests/test_integration/test_full_flow.py new file mode 100644 index 00000000..31f39861 --- /dev/null +++ b/hw5/hw/tests/test_integration/test_full_flow.py @@ -0,0 +1,82 @@ +import pytest +from fastapi import status + +class TestIntegration: + def test_complete_shopping_flow(self, client, db_session): + """Test un flux complet d'achat""" + # 1. Crée quelques items + items_data = [ + {"name": "Laptop", "price": 999.99}, + {"name": "Mouse", "price": 29.99}, + {"name": "Keyboard", "price": 79.99} + ] + + item_ids = [] + for item_data in items_data: + response = client.post("/item/", json=item_data) + assert response.status_code == status.HTTP_201_CREATED + item_ids.append(response.json()["id"]) + + # 2. Crée un panier + cart_response = client.post("/cart/") + assert cart_response.status_code == status.HTTP_201_CREATED + cart_id = cart_response.json()["id"] + + # 3. Ajoute des items au panier + # Ajoute le laptop + response = client.post(f"/cart/{cart_id}/add/{item_ids[0]}") + assert response.status_code == status.HTTP_200_OK + assert response.json()["price"] == 999.99 + + # Ajoute deux souris + response = client.post(f"/cart/{cart_id}/add/{item_ids[1]}") + assert response.status_code == status.HTTP_200_OK + response = client.post(f"/cart/{cart_id}/add/{item_ids[1]}") + assert response.status_code == status.HTTP_200_OK + + # 4. Vérifie le panier final + cart_response = client.get(f"/cart/{cart_id}") + assert cart_response.status_code == status.HTTP_200_OK + cart_data = cart_response.json() + + assert len(cart_data["items"]) == 2 # Laptop + Souris (même item mais agrégé) + assert cart_data["price"] == 999.99 + (29.99 * 2) # 1059.97 + + # Trouve l'item souris dans le panier + mouse_item = next(item for item in cart_data["items"] if item["name"] == "Mouse") + assert mouse_item["quantity"] == 2 + + def test_item_lifecycle(self, client, db_session): + """Test cycle de vie complet d'un item""" + # 1. Création + create_response = client.post("/item/", json={"name": "Test Lifecycle", "price": 100.0}) + assert create_response.status_code == status.HTTP_201_CREATED + item_id = create_response.json()["id"] + + # 2. Lecture + get_response = client.get(f"/item/{item_id}") + assert get_response.status_code == status.HTTP_200_OK + assert get_response.json()["name"] == "Test Lifecycle" + + # 3. Mise à jour partielle + patch_response = client.patch(f"/item/{item_id}", json={"price": 150.0}) + assert patch_response.status_code == status.HTTP_200_OK + assert patch_response.json()["price"] == 150.0 + assert patch_response.json()["name"] == "Test Lifecycle" # Inchangé + + # 4. Mise à jour complète + put_response = client.put( + f"/item/{item_id}", + json={"name": "Updated Lifecycle", "price": 200.0, "deleted": True} + ) + assert put_response.status_code == status.HTTP_200_OK + assert put_response.json()["name"] == "Updated Lifecycle" + assert put_response.json()["deleted"] is True + + # 5. Suppression + delete_response = client.delete(f"/item/{item_id}") + assert delete_response.status_code == status.HTTP_204_NO_CONTENT + + # 6. Vérifie que l'item n'existe plus + final_get = client.get(f"/item/{item_id}") + assert final_get.status_code == status.HTTP_404_NOT_FOUND \ No newline at end of file diff --git a/hw5/hw/tests/test_main.py b/hw5/hw/tests/test_main.py new file mode 100644 index 00000000..4cfc45d6 --- /dev/null +++ b/hw5/hw/tests/test_main.py @@ -0,0 +1,14 @@ +import pytest +from fastapi import status + +class TestMain: + def test_root_endpoint(self, client): + """Test le endpoint racine""" + response = client.get("/") + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"message": "API Shop is running"} + + def test_404_for_unknown_route(self, client): + """Test qu'une route inconnue retourne 404""" + response = client.get("/unknown-route") + assert response.status_code == status.HTTP_404_NOT_FOUND \ No newline at end of file diff --git a/hw5/hw/tests/test_models/test_cart_models.py b/hw5/hw/tests/test_models/test_cart_models.py new file mode 100644 index 00000000..b36abbc3 --- /dev/null +++ b/hw5/hw/tests/test_models/test_cart_models.py @@ -0,0 +1,35 @@ +import pytest +from shop_api.cart.store.models import CartItemInfo, CartInfo, CartEntity + +class TestCartModels: + def test_cart_item_info_creation(self): + """Test la création d'un CartItemInfo""" + item_info = CartItemInfo(id=1, name="Test Item", quantity=3, available=True) + + assert item_info.id == 1 + assert item_info.name == "Test Item" + assert item_info.quantity == 3 + assert item_info.available is True + + def test_cart_info_creation(self): + """Test la création d'un CartInfo""" + items = [ + CartItemInfo(id=1, name="Item1", quantity=2, available=True), + CartItemInfo(id=2, name="Item2", quantity=1, available=False) + ] + cart_info = CartInfo(items=items, price=300.0) + + assert len(cart_info.items) == 2 + assert cart_info.price == 300.0 + assert cart_info.items[0].name == "Item1" + assert cart_info.items[1].available is False + + def test_cart_entity_creation(self): + """Test la création d'un CartEntity""" + items = [CartItemInfo(id=1, name="Test", quantity=1, available=True)] + cart_info = CartInfo(items=items, price=100.0) + cart_entity = CartEntity(id=5, info=cart_info) + + assert cart_entity.id == 5 + assert cart_entity.info.price == 100.0 + assert len(cart_entity.info.items) == 1 \ No newline at end of file diff --git a/hw5/hw/tests/test_models/test_item_models.py b/hw5/hw/tests/test_models/test_item_models.py new file mode 100644 index 00000000..2b725e52 --- /dev/null +++ b/hw5/hw/tests/test_models/test_item_models.py @@ -0,0 +1,38 @@ +import pytest +from shop_api.item.store.models import ItemInfo, ItemEntity, PatchItemInfo + +class TestItemModels: + def test_item_info_creation(self): + """Test la création d'un ItemInfo""" + info = ItemInfo(name="Test Item", price=99.99, deleted=False) + + assert info.name == "Test Item" + assert info.price == 99.99 + assert info.deleted is False + + def test_item_entity_creation(self): + """Test la création d'un ItemEntity""" + info = ItemInfo(name="Test", price=50.0, deleted=True) + entity = ItemEntity(id=1, info=info) + + assert entity.id == 1 + assert entity.info.name == "Test" + assert entity.info.price == 50.0 + assert entity.info.deleted is True + + def test_patch_item_info_partial(self): + """Test PatchItemInfo avec valeurs partielles""" + # Seulement le nom + patch1 = PatchItemInfo(name="Nouveau nom") + assert patch1.name == "Nouveau nom" + assert patch1.price is None + + # Seulement le prix + patch2 = PatchItemInfo(price=150.0) + assert patch2.price == 150.0 + assert patch2.name is None + + # Les deux + patch3 = PatchItemInfo(name="Test", price=200.0) + assert patch3.name == "Test" + assert patch3.price == 200.0 \ No newline at end of file diff --git a/hw5/hw/tests/test_queries/test_cart_queries.py b/hw5/hw/tests/test_queries/test_cart_queries.py new file mode 100644 index 00000000..687a7f31 --- /dev/null +++ b/hw5/hw/tests/test_queries/test_cart_queries.py @@ -0,0 +1,104 @@ +import pytest +from shop_api.cart.store import queries +from shop_api.item.store.models import ItemInfo, ItemEntity, ItemDB + +class TestCartQueries: + def test_create_cart(self, db_session): + """Test la création d'un panier""" + entity = queries.create(db_session) + + assert entity.id is not None + assert entity.info.items == [] + assert entity.info.price == 0.0 + + def test_get_one_cart(self, db_session): + """Test la récupération d'un panier""" + # Crée un panier + created = queries.create(db_session) + + # Récupère + entity = queries.get_one(created.id, db_session) + + assert entity is not None + assert entity.id == created.id + assert entity.info.price == 0.0 + + def test_add_item_to_cart(self, db_session): + """Test l'ajout d'un item au panier""" + # Crée d'abord un item en base de données + item_info = ItemInfo(name="Test Item", price=25.0, deleted=False) + db_item = ItemDB(name=item_info.name, price=item_info.price, deleted=item_info.deleted) + db_session.add(db_item) + db_session.commit() + db_session.refresh(db_item) + + # Crée un panier + cart = queries.create(db_session) + + # Crée l'entité item avec le bon ID + item_entity = ItemEntity(id=db_item.id, info=item_info) + + # Ajoute au panier + updated_cart = queries.add(cart.id, item_entity, db_session) + + assert updated_cart is not None + assert len(updated_cart.info.items) == 1 + assert updated_cart.info.items[0].name == "Test Item" + assert updated_cart.info.items[0].quantity == 1 + assert updated_cart.info.price == 25.0 + + def test_add_item_twice_increases_quantity(self, db_session): + """Test que l'ajout du même item incrémente la quantité""" + # Crée d'abord un item en base + item_info = ItemInfo(name="Test", price=10.0, deleted=False) + db_item = ItemDB(name=item_info.name, price=item_info.price, deleted=item_info.deleted) + db_session.add(db_item) + db_session.commit() + db_session.refresh(db_item) + + # Crée un panier + cart = queries.create(db_session) + item_entity = ItemEntity(id=db_item.id, info=item_info) + + # Ajoute deux fois + queries.add(cart.id, item_entity, db_session) + updated_cart = queries.add(cart.id, item_entity, db_session) + + assert len(updated_cart.info.items) == 1 + assert updated_cart.info.items[0].quantity == 2 + assert updated_cart.info.price == 20.0 + + def test_get_many_carts_with_filters(self, db_session): + """Test la récupération avec filtres""" + # Crée des items en base d'abord + items = [] + for i in range(3): + db_item = ItemDB(name=f"Item{i}", price=10.0 * i, deleted=False) + db_session.add(db_item) + items.append(db_item) + db_session.commit() + for item in items: + db_session.refresh(item) + + # Crée plusieurs paniers avec différents items + carts = [] + for i in range(3): + cart = queries.create(db_session) + if i > 0: # Ajoute des items aux 2 derniers paniers + item_entity = ItemEntity(id=items[i].id, info=ItemInfo(name=items[i].name, price=items[i].price, deleted=False)) + queries.add(cart.id, item_entity, db_session) + carts.append(cart) + + # Filtre par prix min + filtered_carts = list(queries.get_many(db_session, min_price=10.0)) + assert len(filtered_carts) == 2 # Les paniers avec items (prix > 0) + + def test_delete_cart(self, db_session): + """Test la suppression d'un panier""" + cart = queries.create(db_session) + + queries.delete(cart.id, db_session) + + # Vérifie qu'il n'existe plus + result = queries.get_one(cart.id, db_session) + assert result is None \ No newline at end of file diff --git a/hw5/hw/tests/test_queries/test_item_queries.py b/hw5/hw/tests/test_queries/test_item_queries.py new file mode 100644 index 00000000..5f03992d --- /dev/null +++ b/hw5/hw/tests/test_queries/test_item_queries.py @@ -0,0 +1,120 @@ +import pytest +from shop_api.item.store import queries +from shop_api.item.store.models import ItemInfo, PatchItemInfo + +class TestItemQueries: + def test_add_item(self, db_session): + """Test l'ajout d'un item""" + info = ItemInfo(name="Test Item", price=100.0, deleted=False) + + entity = queries.add(info, db_session) + + assert entity.info.name == "Test Item" + assert entity.info.price == 100.0 + assert entity.id is not None + + # Vérifie en base + db_item = db_session.query(queries.ItemDB).filter_by(id=entity.id).first() + assert db_item.name == "Test Item" + + def test_get_one_existing(self, db_session): + """Test la récupération d'un item existant""" + # Crée un item d'abord + info = ItemInfo(name="Get Test", price=50.0, deleted=False) + created = queries.add(info, db_session) + + # Récupère + entity = queries.get_one(created.id, db_session) + + assert entity is not None + assert entity.id == created.id + assert entity.info.name == "Get Test" + + def test_get_one_nonexistent(self, db_session): + """Test la récupération d'un item inexistant""" + entity = queries.get_one(9999, db_session) + assert entity is None + + def test_get_many(self, db_session): + """Test la récupération de plusieurs items""" + # Crée quelques items + items_data = [ + ItemInfo(name="Item1", price=10.0, deleted=False), + ItemInfo(name="Item2", price=20.0, deleted=False), + ItemInfo(name="Item3", price=30.0, deleted=True) # Supprimé + ] + + for info in items_data: + queries.add(info, db_session) + + # Récupère sans les supprimés + entities = list(queries.get_many(db_session, show_deleted=False)) + assert len(entities) == 2 + + # Récupère avec les supprimés + entities = list(queries.get_many(db_session, show_deleted=True)) + assert len(entities) == 3 + + def test_get_many_with_filters(self, db_session): + """Test les filtres prix""" + # Crée des items avec différents prix + prices = [10.0, 25.0, 50.0, 75.0, 100.0] + for price in prices: + queries.add(ItemInfo(name=f"Item{price}", price=price, deleted=False), db_session) + + # Filtre prix min + entities = list(queries.get_many(db_session, min_price=30.0)) + assert len(entities) == 3 # 50, 75, 100 + + # Filtre prix max + entities = list(queries.get_many(db_session, max_price=30.0)) + assert len(entities) == 2 # 10, 25 + + # Filtre min et max + entities = list(queries.get_many(db_session, min_price=20.0, max_price=60.0)) + assert len(entities) == 2 # 25, 50 + + def test_update_item(self, db_session): + """Test la mise à jour complète d'un item""" + # Crée un item + original = queries.add(ItemInfo(name="Original", price=10.0, deleted=False), db_session) + + # Met à jour + new_info = ItemInfo(name="Updated", price=20.0, deleted=True) + updated = queries.update(original.id, new_info, db_session) + + assert updated is not None + assert updated.info.name == "Updated" + assert updated.info.price == 20.0 + assert updated.info.deleted is True + + def test_update_nonexistent(self, db_session): + """Test la mise à jour d'un item inexistant""" + info = ItemInfo(name="Test", price=10.0, deleted=False) + result = queries.update(9999, info, db_session) + assert result is None + + def test_patch_item(self, db_session): + """Test la mise à jour partielle""" + # Crée un item + original = queries.add(ItemInfo(name="Original", price=10.0, deleted=False), db_session) + + # Patch seulement le nom + patch_info = PatchItemInfo(name="Patched") + patched = queries.patch(original.id, patch_info, db_session) + + assert patched is not None + assert patched.info.name == "Patched" + assert patched.info.price == 10.0 # Inchangé + + def test_delete_item(self, db_session): + """Test la suppression d'un item""" + # Crée un item + entity = queries.add(ItemInfo(name="To Delete", price=10.0, deleted=False), db_session) + + # Supprime + queries.delete(entity.id, db_session) + + # Vérifie qu'il n'existe plus + result = queries.get_one(entity.id, db_session) + assert result is None \ No newline at end of file diff --git a/hw5/hw/tests/test_routers/test_cart_routers.py b/hw5/hw/tests/test_routers/test_cart_routers.py new file mode 100644 index 00000000..8447fff5 --- /dev/null +++ b/hw5/hw/tests/test_routers/test_cart_routers.py @@ -0,0 +1,97 @@ +import pytest +from fastapi import status + +class TestCartRouters: + def test_create_cart_success(self, client, db_session): + """Test la création d'un panier via API""" + response = client.post("/cart/") + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert "id" in data + assert data["items"] == [] + assert data["price"] == 0.0 + assert "location" in response.headers + + def test_get_cart_by_id_success(self, client, db_session): + """Test la récupération d'un panier existant""" + # Crée un panier d'abord + create_response = client.post("/cart/") + cart_id = create_response.json()["id"] + + # Récupère + response = client.get(f"/cart/{cart_id}") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["id"] == cart_id + assert data["items"] == [] + assert data["price"] == 0.0 + + def test_get_cart_by_id_not_found(self, client): + """Test la récupération d'un panier inexistant""" + response = client.get("/cart/9999") + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_get_cart_list(self, client, db_session): + """Test la liste des paniers""" + # Crée quelques paniers + for i in range(3): + client.post("/cart/") + + response = client.get("/cart/") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) >= 3 + + def test_add_item_to_cart_success(self, client, db_session): + """Test l'ajout d'un item au panier""" + # Crée un panier + cart_response = client.post("/cart/") + cart_id = cart_response.json()["id"] + + # Crée un item + item_response = client.post("/item/", json={"name": "Cart Item", "price": 25.0}) + item_id = item_response.json()["id"] + + # Ajoute au panier + response = client.post(f"/cart/{cart_id}/add/{item_id}") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data["items"]) == 1 + assert data["items"][0]["name"] == "Cart Item" + assert data["items"][0]["quantity"] == 1 + assert data["price"] == 25.0 + + def test_add_item_to_cart_cart_not_found(self, client, db_session): + """Test l'ajout avec panier inexistant""" + # Crée un item + item_response = client.post("/item/", json={"name": "Test", "price": 10.0}) + item_id = item_response.json()["id"] + + response = client.post(f"/cart/9999/add/{item_id}") + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_add_item_to_cart_item_not_found(self, client, db_session): + """Test l'ajout avec item inexistant""" + # Crée un panier + cart_response = client.post("/cart/") + cart_id = cart_response.json()["id"] + + response = client.post(f"/cart/{cart_id}/add/9999") + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_delete_cart_success(self, client, db_session): + """Test la suppression d'un panier""" + # Crée un panier + create_response = client.post("/cart/") + cart_id = create_response.json()["id"] + + # Supprime + response = client.delete(f"/cart/{cart_id}") + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Vérifie qu'il n'existe plus + get_response = client.get(f"/cart/{cart_id}") + assert get_response.status_code == status.HTTP_404_NOT_FOUND \ No newline at end of file diff --git a/hw5/hw/tests/test_routers/test_item_routers.py b/hw5/hw/tests/test_routers/test_item_routers.py new file mode 100644 index 00000000..f75a0f0a --- /dev/null +++ b/hw5/hw/tests/test_routers/test_item_routers.py @@ -0,0 +1,143 @@ +import pytest +from fastapi import status +from shop_api.item.store.models import ItemInfo + +class TestItemRouters: + def test_create_item_success(self, client, db_session): + """Test la création d'un item via API""" + response = client.post( + "/item/", + json={"name": "API Test Item", "price": 99.99} + ) + + assert response.status_code == status.HTTP_201_CREATED + data = response.json() + assert data["name"] == "API Test Item" + assert data["price"] == 99.99 + assert data["deleted"] is False + assert "id" in data + assert "location" in response.headers + + def test_create_item_invalid_data(self, client): + """Test la création avec données invalides""" + # Prix négatif + response = client.post( + "/item/", + json={"name": "Test", "price": -10.0} + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + # Données manquantes + response = client.post( + "/item/", + json={"name": "Test"} # price manquant + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + + def test_get_item_by_id_success(self, client, db_session): + """Test la récupération d'un item existant""" + # Crée un item d'abord + create_response = client.post("/item/", json={"name": "Get Test", "price": 50.0}) + item_id = create_response.json()["id"] + + # Récupère + response = client.get(f"/item/{item_id}") + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["name"] == "Get Test" + assert data["price"] == 50.0 + assert data["id"] == item_id + + def test_get_item_by_id_not_found(self, client): + """Test la récupération d'un item inexistant""" + response = client.get("/item/9999") + assert response.status_code == status.HTTP_404_NOT_FOUND + assert "not found" in response.json()["detail"].lower() + + def test_get_item_list(self, client, db_session): + """Test la liste des items avec filtres""" + # Crée quelques items + items_data = [ + {"name": "Item10", "price": 10.0}, + {"name": "Item30", "price": 30.0}, + {"name": "Item50", "price": 50.0} + ] + for item_data in items_data: + client.post("/item/", json=item_data) + + # Récupère tous + response = client.get("/item/") + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert len(data) >= 3 + + # Avec filtre prix min + response = client.get("/item/?min_price=25.0") + assert response.status_code == status.HTTP_200_OK + data = response.json() + # Vérifie que tous les items ont prix >= 25 + for item in data: + assert item["price"] >= 25.0 + + def test_update_item_success(self, client, db_session): + """Test la mise à jour complète d'un item""" + # Crée un item + create_response = client.post("/item/", json={"name": "Original", "price": 10.0}) + item_id = create_response.json()["id"] + + # Met à jour + response = client.put( + f"/item/{item_id}", + json={"name": "Updated", "price": 20.0, "deleted": True} + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["name"] == "Updated" + assert data["price"] == 20.0 + assert data["deleted"] is True + + def test_update_item_not_found(self, client): + """Test la mise à jour d'un item inexistant""" + response = client.put( + "/item/9999", + json={"name": "Test", "price": 10.0} + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_patch_item_success(self, client, db_session): + """Test la mise à jour partielle""" + # Crée un item + create_response = client.post("/item/", json={"name": "Original", "price": 10.0}) + item_id = create_response.json()["id"] + + # Patch seulement le nom + response = client.patch( + f"/item/{item_id}", + json={"name": "Patched"} + ) + + assert response.status_code == status.HTTP_200_OK + data = response.json() + assert data["name"] == "Patched" + assert data["price"] == 10.0 # Inchangé + + def test_patch_item_not_found(self, client): + """Test le patch d'un item inexistant""" + response = client.patch("/item/9999", json={"name": "Test"}) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_delete_item_success(self, client, db_session): + """Test la suppression d'un item""" + # Crée un item + create_response = client.post("/item/", json={"name": "To Delete", "price": 10.0}) + item_id = create_response.json()["id"] + + # Supprime + response = client.delete(f"/item/{item_id}") + assert response.status_code == status.HTTP_204_NO_CONTENT + + # Vérifie qu'il n'existe plus + get_response = client.get(f"/item/{item_id}") + assert get_response.status_code == status.HTTP_404_NOT_FOUND \ No newline at end of file diff --git a/hw5/hw/transaction_demo.py b/hw5/hw/transaction_demo.py new file mode 100644 index 00000000..e00c6c7c --- /dev/null +++ b/hw5/hw/transaction_demo.py @@ -0,0 +1,355 @@ +import threading +import time +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker +from database import SessionLocal +from shop_api.item.store.models import ItemDB +from shop_api.cart.store.models import CartDB + +# Конфигурация базы данных +DATABASE_URL = "postgresql://postgres:password@postgres:5432/shop_db" +engine = create_engine(DATABASE_URL) +Session = sessionmaker(bind=engine) + +def setup_test_data(): + """Подготовка тестовых данных используя существующие модели""" + session = Session() + try: + # Очистка тестовых данных + session.query(ItemDB).filter(ItemDB.name.in_(["TestItem1", "TestItem2", "TestItem3"])).delete() + session.commit() + + # Создание тестовых товаров используя модель ItemDB + test_items = [ + ItemDB(name="TestItem1", price=100.0, deleted=False), + ItemDB(name="TestItem2", price=200.0, deleted=False), + ItemDB(name="TestItem3", price=300.0, deleted=False) + ] + + session.add_all(test_items) + session.commit() + print("Тестовые данные созданы с использованием существующих моделей") + + except Exception as e: + session.rollback() + print(f"Ошибка: {e}") + finally: + session.close() + +def dirty_read_demo(): + """Демонстрация Dirty Read (грязное чтение)""" + print("\n" + "="*50) + print("DIRTY READ - READ UNCOMMITTED") + print("="*50) + + def transaction1(): + """Транзакция, которая изменяет и откатывает""" + session = Session() + try: + print("Транзакция 1: Изменяю цену TestItem1 +50€ (без коммита)") + session.execute(text("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED")) + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem1").first() + original_price = item.price + item.price = item.price + 50 + print(f"Транзакция 1: Изменил цену с {original_price} на {item.price}") + + time.sleep(3) # Пауза для чтения Т2 + print("Транзакция 1: Делаю rollback!") + session.rollback() + + finally: + session.close() + + def transaction2(): + """Транзакция, которая читает незакоммиченные данные""" + session = Session() + try: + time.sleep(1) # Ждет изменения Т1 + print("Транзакция 2: Читаю цену TestItem1...") + session.execute(text("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED")) + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem1").first() + print(f"Транзакция 2: Вижу {item.price}€ -> DIRTY READ!") + + finally: + session.close() + + t1 = threading.Thread(target=transaction1) + t2 = threading.Thread(target=transaction2) + + t1.start() + t2.start() + t1.join() + t2.join() + +def no_dirty_read_demo(): + """Демонстрация отсутствия Dirty Read с READ COMMITTED""" + print("\n" + "="*50) + print("НЕТ DIRTY READ - READ COMMITTED") + print("="*50) + + def transaction1(): + session = Session() + try: + print("Транзакция 1: Изменяю цену TestItem1 +50€ (без коммита)") + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem1").first() + original_price = item.price + item.price = item.price + 50 + print(f"Транзакция 1: Изменил цену с {original_price} на {item.price}") + + time.sleep(3) + print("Транзакция 1: Делаю rollback!") + session.rollback() + + finally: + session.close() + + def transaction2(): + session = Session() + try: + time.sleep(1) + print("Транзакция 2: Читаю цену TestItem1...") + session.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem1").first() + print(f"Транзакция 2: Вижу {item.price}€ -> Чистые данные!") + + finally: + session.close() + + t1 = threading.Thread(target=transaction1) + t2 = threading.Thread(target=transaction2) + + t1.start() + t2.start() + t1.join() + t2.join() + +def non_repeatable_read_demo(): + """Демонстрация Non-Repeatable Read с READ COMMITTED""" + print("\n" + "="*50) + print("NON-REPEATABLE READ - READ COMMITTED") + print("="*50) + + def transaction1(): + session = Session() + try: + time.sleep(1) + print("Транзакция 1: Изменяю TestItem2 +100€ и коммит") + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + original_price = item.price + item.price = item.price + 100 + session.commit() + print(f"Транзакция 1: Изменил цену с {original_price} на {item.price} и закоммитил") + + finally: + session.close() + + def transaction2(): + session = Session() + try: + session.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + + # Первое чтение + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + price1 = item.price + print(f"Транзакция 2: Первое чтение -> {price1}€") + + time.sleep(2) # Ждет изменения Т1 + + # Второе чтение + session.expire_all() # Сбрасываем кэш + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + price2 = item.price + print(f"Транзакция 2: Второе чтение -> {price2}€") + + if price1 != price2: + print("NON-REPEATABLE READ обнаружен!") + + finally: + session.close() + + t2 = threading.Thread(target=transaction2) + t1 = threading.Thread(target=transaction1) + + t2.start() + t1.start() + t1.join() + t2.join() + +def no_non_repeatable_read_demo(): + """Демонстрация отсутствия Non-Repeatable Read с REPEATABLE READ""" + print("\n" + "="*50) + print("НЕТ NON-REPEATABLE READ - REPEATABLE READ") + print("="*50) + + def transaction1(): + session = Session() + try: + time.sleep(1) + print("Транзакция 1: Изменяю TestItem2 +150€ и коммит") + + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + original_price = item.price + item.price = item.price + 150 + session.commit() + print(f"Транзакция 1: Изменил цену с {original_price} на {item.price} и закоммитил") + + finally: + session.close() + + def transaction2(): + session = Session() + try: + session.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + + # Первое чтение + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + price1 = item.price + print(f"Транзакция 2: Первое чтение -> {price1}€") + + time.sleep(2) # Ждет изменения Т1 + + # Второе чтение + item = session.query(ItemDB).filter(ItemDB.name == "TestItem2").first() + price2 = item.price + print(f"Транзакция 2: Второе чтение -> {price2}€") + + if price1 == price2: + print("Нет Non-Repeatable Read с REPEATABLE READ!") + + finally: + session.close() + + t2 = threading.Thread(target=transaction2) + t1 = threading.Thread(target=transaction1) + + t2.start() + t1.start() + t1.join() + t2.join() + +def phantom_read_demo(): + """Демонстрация Phantom Read с REPEATABLE READ""" + print("\n" + "="*50) + print("PHANTOM READ - REPEATABLE READ") + print("="*50) + + def transaction1(): + session = Session() + try: + time.sleep(1) + print("Транзакция 1: Добавляю новый товар 'PhantomItem'") + + new_item = ItemDB(name="PhantomItem", price=400.0, deleted=False) + session.add(new_item) + session.commit() + print("Транзакция 1: Новый товар добавлен и закоммичен!") + + finally: + session.close() + + def transaction2(): + session = Session() + try: + session.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + + # Первое чтение + items = session.query(ItemDB).filter(ItemDB.price > 100).all() + count1 = len(items) + print(f"Транзакция 2: Первый подсчет -> {count1} товаров") + + time.sleep(2) # Ждет добавления Т1 + + # Второе чтение + items = session.query(ItemDB).filter(ItemDB.price > 100).all() + count2 = len(items) + print(f"Транзакция 2: Второй подсчет -> {count2} товаров") + + if count1 != count2: + print("PHANTOM READ обнаружен!") + + finally: + session.close() + + t2 = threading.Thread(target=transaction2) + t1 = threading.Thread(target=transaction1) + + t2.start() + t1.start() + t1.join() + t2.join() + +def no_phantom_read_demo(): + """Демонстрация отсутствия Phantom Read с SERIALIZABLE""" + print("\n" + "="*50) + print("НЕТ PHANTOM READ - SERIALIZABLE") + print("="*50) + + def transaction1(): + session = Session() + try: + time.sleep(1) + print("Транзакция 1: Добавляю новый товар 'SerializableItem'") + + new_item = ItemDB(name="SerializableItem", price=500.0, deleted=False) + session.add(new_item) + session.commit() + print("Транзакция 1: Новый товар добавлен и закоммичен!") + + finally: + session.close() + + def transaction2(): + session = Session() + try: + session.execute(text("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")) + + # Первое чтение + items = session.query(ItemDB).filter(ItemDB.price > 100).all() + count1 = len(items) + print(f"Транзакция 2: Первый подсчет -> {count1} товаров") + + time.sleep(2) # Ждет добавления Т1 + + # Второе чтение + items = session.query(ItemDB).filter(ItemDB.price > 100).all() + count2 = len(items) + print(f"Транзакция 2: Второй подсчет -> {count2} товаров") + + if count1 == count2: + print("Нет Phantom Read с SERIALIZABLE!") + + finally: + session.close() + + t2 = threading.Thread(target=transaction2) + t1 = threading.Thread(target=transaction1) + + t2.start() + t1.start() + t1.join() + t2.join() + +if __name__ == "__main__": + print("ДЕМОНСТРАЦИЯ ПРОБЛЕМ ТРАНЗАКЦИЙ") + print("База данных: PostgreSQL с SQLAlchemy") + print("Используются существующие модели ItemDB") + + # Подготовка + setup_test_data() + + # Демонстрации + dirty_read_demo() # 1. Dirty Read с READ UNCOMMITTED + no_dirty_read_demo() # 2. Нет Dirty Read с READ COMMITTED + non_repeatable_read_demo() # 3. Non-repeatable Read с READ COMMITTED + no_non_repeatable_read_demo() # 4. Нет Non-repeatable Read с REPEATABLE READ + phantom_read_demo() # 5. Phantom Read с REPEATABLE READ + no_phantom_read_demo() # 6. Нет Phantom Read с SERIALIZABLE + + print("\n" + "="*50) + print("ДЕМОНСТРАЦИЯ ЗАВЕРШЕНА!") + print("="*50) \ No newline at end of file From 767bdbce5b7555fdf065c935745e656c52aff66e Mon Sep 17 00:00:00 2001 From: sidoine Date: Mon, 20 Oct 2025 16:32:27 +0300 Subject: [PATCH 06/48] HW5 --- .github/workflows/h5-tests.yml | 66 ++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 .github/workflows/h5-tests.yml diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml new file mode 100644 index 00000000..7921b758 --- /dev/null +++ b/.github/workflows/h5-tests.yml @@ -0,0 +1,66 @@ +name: "HW5 Tests" + +# Se déclenche seulement pour les changements dans HW5/hw/ +on: + pull_request: + branches: [ main, master ] + paths: [ 'HW5/hw/**' ] + push: + branches: [ main, master ] + paths: [ 'HW5/hw/**' ] + +jobs: + test-hw5: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.13"] # ✅ Test avec 2 versions Python + + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: test_db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} # ✅ Version dynamique + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + working-directory: HW5/hw + run: | + python -m pip install --upgrade pip + pip install -r requirements-test.txt + + - name: Run tests with coverage + working-directory: HW5/hw + env: + DATABASE_URL: postgresql://postgres:password@postgres:5432/test_db + run: | + pytest --cov=shop_api --cov-report=xml --cov-report=term-missing + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: HW5/hw/coverage.xml + flags: unittests + fail_ci_if_error: false + + - name: Check 95% coverage threshold + working-directory: HW5/hw + run: | + python -m coverage report --fail-under=95 \ No newline at end of file From 82834c81b751c7451e084f7eb9782cd08b2d55d0 Mon Sep 17 00:00:00 2001 From: sidoine Date: Mon, 20 Oct 2025 16:32:45 +0300 Subject: [PATCH 07/48] HW5 --- .github/workflows/hw2-tests.yml | 59 ++++++++++++++++++++++++--------- hw5/hw/.githubworkflowsci.yml | 57 ------------------------------- 2 files changed, 43 insertions(+), 73 deletions(-) delete mode 100644 hw5/hw/.githubworkflowsci.yml diff --git a/.github/workflows/hw2-tests.yml b/.github/workflows/hw2-tests.yml index be7fc297..7b95f9f3 100644 --- a/.github/workflows/hw2-tests.yml +++ b/.github/workflows/hw2-tests.yml @@ -1,39 +1,66 @@ -name: "HW2 Tests" +name: "HW5 Tests" + -# Запускаем тесты при изменении файлов в hw2/hw/ on: pull_request: - branches: [ main ] - paths: [ 'hw2/hw/**' ] + branches: [ main, master ] + paths: [ 'HW5/hw/**' ] push: - branches: [ main ] - paths: [ 'hw2/hw/**' ] + branches: [ main, master ] + paths: [ 'HW5/hw/**' ] jobs: - test-hw2: + test-hw5: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.12", "3.13"] + python-version: ["3.12", "3.13"] + services: + postgres: + image: postgres:15 + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: password + POSTGRES_DB: test_db + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: - name: Checkout code uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies - working-directory: hw2/hw + working-directory: HW5/hw run: | python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Run tests - working-directory: hw2/hw + pip install -r requirements-test.txt + + - name: Run tests with coverage + working-directory: HW5/hw env: - PYTHONPATH: ${{ github.workspace }}/hw2/hw + DATABASE_URL: postgresql://postgres:password@postgres:5432/test_db + run: | + pytest --cov=shop_api --cov-report=xml --cov-report=term-missing + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v3 + with: + file: HW5/hw/coverage.xml + flags: unittests + fail_ci_if_error: false + + - name: Check 95% coverage threshold + working-directory: HW5/hw run: | - pytest test_homework2.py -v + python -m coverage report --fail-under=95 \ No newline at end of file diff --git a/hw5/hw/.githubworkflowsci.yml b/hw5/hw/.githubworkflowsci.yml deleted file mode 100644 index 7226c5e2..00000000 --- a/hw5/hw/.githubworkflowsci.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: CI Tests and Coverage - -on: - push: - branches: [ main, master ] - pull_request: - branches: [ main, master ] - -jobs: - test: - runs-on: ubuntu-latest - - services: - postgres: - image: postgres:15 - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: password - POSTGRES_DB: test_db - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.12' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements-test.txt - - - name: Run tests with coverage - env: - DATABASE_URL: postgresql://postgres:password@postgres:5432/test_db # ✅ Correction ici - run: | - pytest --cov=shop_api --cov-report=xml --cov-report=term-missing - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - with: - file: ./coverage.xml - flags: unittests - fail_ci_if_error: false - - - name: Check 95% coverage threshold - run: | - python -m coverage report --fail-under=95 \ No newline at end of file From 74fade036eee7f78bf8e8913c4912091d617dd81 Mon Sep 17 00:00:00 2001 From: sidoine Date: Mon, 20 Oct 2025 16:33:42 +0300 Subject: [PATCH 08/48] HW5 --- .github/workflows/h5-tests.yml | 5 ++- .github/workflows/hw2-tests.yml | 59 +++++++++------------------------ 2 files changed, 18 insertions(+), 46 deletions(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index 7921b758..86a0ee9f 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -1,6 +1,5 @@ name: "HW5 Tests" -# Se déclenche seulement pour les changements dans HW5/hw/ on: pull_request: branches: [ main, master ] @@ -14,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.12", "3.13"] # ✅ Test avec 2 versions Python + python-version: ["3.12", "3.13"] services: postgres: @@ -35,7 +34,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} # ✅ Version dynamique + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} diff --git a/.github/workflows/hw2-tests.yml b/.github/workflows/hw2-tests.yml index 7b95f9f3..be7fc297 100644 --- a/.github/workflows/hw2-tests.yml +++ b/.github/workflows/hw2-tests.yml @@ -1,66 +1,39 @@ -name: "HW5 Tests" - +name: "HW2 Tests" +# Запускаем тесты при изменении файлов в hw2/hw/ on: pull_request: - branches: [ main, master ] - paths: [ 'HW5/hw/**' ] + branches: [ main ] + paths: [ 'hw2/hw/**' ] push: - branches: [ main, master ] - paths: [ 'HW5/hw/**' ] + branches: [ main ] + paths: [ 'hw2/hw/**' ] jobs: - test-hw5: + test-hw2: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.12", "3.13"] + python-version: ["3.12", "3.13"] - services: - postgres: - image: postgres:15 - env: - POSTGRES_USER: postgres - POSTGRES_PASSWORD: password - POSTGRES_DB: test_db - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - ports: - - 5432:5432 - steps: - name: Checkout code uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies - working-directory: HW5/hw + working-directory: hw2/hw run: | python -m pip install --upgrade pip - pip install -r requirements-test.txt - - - name: Run tests with coverage - working-directory: HW5/hw - env: - DATABASE_URL: postgresql://postgres:password@postgres:5432/test_db - run: | - pytest --cov=shop_api --cov-report=xml --cov-report=term-missing - - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - with: - file: HW5/hw/coverage.xml - flags: unittests - fail_ci_if_error: false + pip install -r requirements.txt - - name: Check 95% coverage threshold - working-directory: HW5/hw + - name: Run tests + working-directory: hw2/hw + env: + PYTHONPATH: ${{ github.workspace }}/hw2/hw run: | - python -m coverage report --fail-under=95 \ No newline at end of file + pytest test_homework2.py -v From 4d91d7f008afe14f22925ed17d56f8789d41974f Mon Sep 17 00:00:00 2001 From: sidoine Date: Mon, 20 Oct 2025 16:43:29 +0300 Subject: [PATCH 09/48] test: Trigger HW5 CI pipeline --- hw5/hw/database.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/hw5/hw/database.py b/hw5/hw/database.py index baf17d19..a5e960c7 100644 --- a/hw5/hw/database.py +++ b/hw5/hw/database.py @@ -5,28 +5,21 @@ import os -# URL de connexion à PostgreSQL -# Dans ton conftest.py ou database.py - if os.getenv("TESTING"): # Pour les tests - SQLite en mémoire (rapide et isolé) DATABASE_URL = "sqlite:///:memory:" # ou DATABASE_URL = "sqlite:///./test.db" else: - # Pour le développement - utilise ton URL normale + DATABASE_URL = "postgresql://postgres:password@postgres:5432/shop_db" -# Moteur de connexion engine = create_engine(DATABASE_URL) -# Session pour interagir avec la base SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) -# Base pour tous nos modèles Base = declarative_base() -# Fonction pour obtenir une session (utilisée dans les routes) def get_db(): db = SessionLocal() try: From 4ab5787c3af5daf8d2c1000694610bf4f8bab218 Mon Sep 17 00:00:00 2001 From: sidoine Date: Mon, 20 Oct 2025 16:47:15 +0300 Subject: [PATCH 10/48] test: Trigger HW5 CI pipeline --- .github/workflows/h5-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index 86a0ee9f..733d256e 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -1,5 +1,5 @@ name: "HW5 Tests" - +# on: pull_request: branches: [ main, master ] From a28a2940400e634a5429e7e3c148513a660fbbdb Mon Sep 17 00:00:00 2001 From: sidoine Date: Mon, 20 Oct 2025 17:00:41 +0300 Subject: [PATCH 11/48] feat: Add HW5 CI/CD workflow --- .github/workflows/h5-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index 733d256e..86a0ee9f 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -1,5 +1,5 @@ name: "HW5 Tests" -# + on: pull_request: branches: [ main, master ] From 5974481428c06612de42889b7bc74887f04b349a Mon Sep 17 00:00:00 2001 From: sidoine Date: Mon, 20 Oct 2025 17:03:23 +0300 Subject: [PATCH 12/48] test: Trigger HW5 CI --- hw5/hw/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/hw5/hw/README.md b/hw5/hw/README.md index ba9f23c8..cc08c034 100644 --- a/hw5/hw/README.md +++ b/hw5/hw/README.md @@ -121,3 +121,4 @@ export PYTHONPATH=${PWD}/hw2/hw начале в следующем виде: `{username} :: {message}`. Если делаете его, напишите, пожалуйста, прямо в PR-e об этом. Мне будет сильно проще это заметить<3 +"# CI Test" From ec2423d0f66a3830dcddb48873e14f8da872443d Mon Sep 17 00:00:00 2001 From: sidoine Date: Mon, 20 Oct 2025 17:10:29 +0300 Subject: [PATCH 13/48] test: Add debug workflow --- .github/workflows/debug.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/workflows/debug.yml diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml new file mode 100644 index 00000000..a3a3bbe0 --- /dev/null +++ b/.github/workflows/debug.yml @@ -0,0 +1,20 @@ +name: "Debug HW5" + +on: [push, pull_request] + +jobs: + debug: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Debug structure + run: | + echo "=== Structure du repo ===" + ls -la + echo "=== Contenu HW5 ===" + ls -la HW5/ + echo "=== Contenu HW5/hw ===" + ls -la HW5/hw/ \ No newline at end of file From 9f429abd9f884dd5f953815dd485effcfdff2aa8 Mon Sep 17 00:00:00 2001 From: sidoine Date: Mon, 20 Oct 2025 17:18:57 +0300 Subject: [PATCH 14/48] fix: Correct folder name from HW5 to hw5 --- .github/workflows/h5-tests.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index 86a0ee9f..c2582108 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -3,10 +3,10 @@ name: "HW5 Tests" on: pull_request: branches: [ main, master ] - paths: [ 'HW5/hw/**' ] + paths: [ 'hw5/hw/**' ] push: branches: [ main, master ] - paths: [ 'HW5/hw/**' ] + paths: [ 'hw5/hw/**' ] jobs: test-hw5: @@ -40,13 +40,13 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies - working-directory: HW5/hw + working-directory: hw5/hw run: | python -m pip install --upgrade pip pip install -r requirements-test.txt - name: Run tests with coverage - working-directory: HW5/hw + working-directory: hw5/hw env: DATABASE_URL: postgresql://postgres:password@postgres:5432/test_db run: | @@ -55,11 +55,11 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: - file: HW5/hw/coverage.xml + file: hw5/hw/coverage.xml flags: unittests fail_ci_if_error: false - name: Check 95% coverage threshold - working-directory: HW5/hw + working-directory: hw5/hw run: | python -m coverage report --fail-under=95 \ No newline at end of file From ee31bc14e71d454b7dfada7207a04f765d7d1c0c Mon Sep 17 00:00:00 2001 From: sidoine Date: Mon, 20 Oct 2025 17:24:40 +0300 Subject: [PATCH 15/48] test: Trigger HW5 CI with corrected paths --- .github/workflows/debug.yml | 20 -------------------- .github/workflows/h5-tests.yml | 2 +- hw5/hw/database.py | 2 +- 3 files changed, 2 insertions(+), 22 deletions(-) delete mode 100644 .github/workflows/debug.yml diff --git a/.github/workflows/debug.yml b/.github/workflows/debug.yml deleted file mode 100644 index a3a3bbe0..00000000 --- a/.github/workflows/debug.yml +++ /dev/null @@ -1,20 +0,0 @@ -name: "Debug HW5" - -on: [push, pull_request] - -jobs: - debug: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Debug structure - run: | - echo "=== Structure du repo ===" - ls -la - echo "=== Contenu HW5 ===" - ls -la HW5/ - echo "=== Contenu HW5/hw ===" - ls -la HW5/hw/ \ No newline at end of file diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index c2582108..237f54ad 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -1,5 +1,5 @@ name: "HW5 Tests" - +# on: pull_request: branches: [ main, master ] diff --git a/hw5/hw/database.py b/hw5/hw/database.py index a5e960c7..3cd5602d 100644 --- a/hw5/hw/database.py +++ b/hw5/hw/database.py @@ -2,7 +2,7 @@ from sqlalchemy.orm import sessionmaker #from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import declarative_base - +# import os if os.getenv("TESTING"): From 25ed863c63c461e0064c1073e40ded2a582d98f0 Mon Sep 17 00:00:00 2001 From: sidoine Date: Mon, 20 Oct 2025 17:43:22 +0300 Subject: [PATCH 16/48] test: Trigger HW5 CI with corrected paths --- hw5/hw/test.db | Bin 28672 -> 28672 bytes hw5/hw/tests/conftest.py | 31 +++++++++++++++++++------------ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/hw5/hw/test.db b/hw5/hw/test.db index cd97277f80825fe01c3a79ad3979ac4b8fe58358..8ee0fb9ccdcf3250589ca445e34a2e557c2765fa 100644 GIT binary patch delta 37 ncmZp8z}WDBae|Z(e>MXH13MJ6F);9an5bjK#Gkz}VL?6spz{cn delta 37 ncmZp8z}WDBae|Z(_ihFT26iZBV_@LCF;T~eiF^0Pga!EksXqx3 diff --git a/hw5/hw/tests/conftest.py b/hw5/hw/tests/conftest.py index 5ae7448e..f6869609 100644 --- a/hw5/hw/tests/conftest.py +++ b/hw5/hw/tests/conftest.py @@ -1,18 +1,27 @@ import pytest import sys -sys.path.append('/app') +import os + +# Détecte l'environnement +IS_CI = os.getenv('GITHUB_ACTIONS') == 'true' + +if IS_CI: + # Chemin pour GitHub Actions + sys.path.append('/home/runner/work/python-backend-hw/python-backend-hw/hw5/hw') + from shop_api.database import Base, get_db + from shop_api.main import app + TEST_DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:password@postgres:5432/test_db") +else: + # Chemin pour Docker local + sys.path.append('/app') + from database import Base, get_db + from main import app + TEST_DATABASE_URL = "sqlite:///./test.db" from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker - -# SOLUTION : Utilise TestClient depuis fastapi, PAS starlette from fastapi.testclient import TestClient -from database import Base, get_db -from main import app - -# Base de données de test -TEST_DATABASE_URL = "sqlite:///./test.db" engine = create_engine(TEST_DATABASE_URL) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @@ -24,7 +33,8 @@ def db_session(): yield session finally: session.close() - Base.metadata.drop_all(bind=engine) + if not IS_CI: # Ne drop que en local + Base.metadata.drop_all(bind=engine) @pytest.fixture(scope="function") def client(db_session): @@ -32,9 +42,6 @@ def override_get_db(): yield db_session app.dependency_overrides[get_db] = override_get_db - - # CORRECTION : Simple création sans problème de version client = TestClient(app) yield client - app.dependency_overrides.clear() \ No newline at end of file From 65c73cd3bf55e241801b998ac72fa6d1a1095458 Mon Sep 17 00:00:00 2001 From: sidoine Date: Mon, 20 Oct 2025 17:54:07 +0300 Subject: [PATCH 17/48] test: Trigger HW5 CI with corrected paths --- .github/workflows/h5-tests.yml | 25 +++++++++++++------------ hw5/hw/tests/conftest.py | 13 +++++++++---- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index 237f54ad..a8aff16c 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -1,19 +1,20 @@ name: "HW5 Tests" -# + on: pull_request: branches: [ main, master ] - paths: [ 'hw5/hw/**' ] + paths: [ 'hw5/hw/**' ] push: branches: [ main, master ] - paths: [ 'hw5/hw/**' ] + paths: [ 'hw5/hw/**' ] jobs: test-hw5: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12", "3.13"] + # ✅ Supprimez la matrix pour tester avec 3.12 uniquement + # strategy: + # matrix: + # python-version: ["3.12", "3.13"] services: postgres: @@ -34,19 +35,19 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python uses: actions/setup-python@v4 with: - python-version: ${{ matrix.python-version }} + python-version: "3.12" # ✅ Version fixe - name: Install dependencies - working-directory: hw5/hw + working-directory: hw5/hw run: | python -m pip install --upgrade pip pip install -r requirements-test.txt - name: Run tests with coverage - working-directory: hw5/hw + working-directory: hw5/hw env: DATABASE_URL: postgresql://postgres:password@postgres:5432/test_db run: | @@ -55,11 +56,11 @@ jobs: - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: - file: hw5/hw/coverage.xml + file: hw5/hw/coverage.xml flags: unittests fail_ci_if_error: false - name: Check 95% coverage threshold - working-directory: hw5/hw + working-directory: hw5/hw run: | python -m coverage report --fail-under=95 \ No newline at end of file diff --git a/hw5/hw/tests/conftest.py b/hw5/hw/tests/conftest.py index f6869609..71de8106 100644 --- a/hw5/hw/tests/conftest.py +++ b/hw5/hw/tests/conftest.py @@ -6,10 +6,15 @@ IS_CI = os.getenv('GITHUB_ACTIONS') == 'true' if IS_CI: - # Chemin pour GitHub Actions - sys.path.append('/home/runner/work/python-backend-hw/python-backend-hw/hw5/hw') - from shop_api.database import Base, get_db - from shop_api.main import app + # Chemin pour GitHub Actions - CORRIGÉ + sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + try: + from shop_api.database import Base, get_db + from shop_api.main import app + except ImportError: + # Fallback si la structure est différente + from database import Base, get_db + from main import app TEST_DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:password@postgres:5432/test_db") else: # Chemin pour Docker local From 82365352500b9e4a6dbd4871b020d201f9dc6598 Mon Sep 17 00:00:00 2001 From: sidoine Date: Mon, 20 Oct 2025 17:57:24 +0300 Subject: [PATCH 18/48] test: Trigger HW5 CI with corrected paths --- hw5/hw/tests/conftest.py | 37 ++++++++++++++++--------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/hw5/hw/tests/conftest.py b/hw5/hw/tests/conftest.py index 71de8106..7a66762e 100644 --- a/hw5/hw/tests/conftest.py +++ b/hw5/hw/tests/conftest.py @@ -2,31 +2,26 @@ import sys import os -# Détecte l'environnement -IS_CI = os.getenv('GITHUB_ACTIONS') == 'true' - -if IS_CI: - # Chemin pour GitHub Actions - CORRIGÉ - sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) - try: - from shop_api.database import Base, get_db - from shop_api.main import app - except ImportError: - # Fallback si la structure est différente - from database import Base, get_db - from main import app - TEST_DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:password@postgres:5432/test_db") -else: - # Chemin pour Docker local - sys.path.append('/app') - from database import Base, get_db - from main import app - TEST_DATABASE_URL = "sqlite:///./test.db" +# Ajoute le répertoire parent au chemin Python +current_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.dirname(current_dir) +sys.path.insert(0, project_root) from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from fastapi.testclient import TestClient +# Import depuis shop_api (votre structure correcte) +from shop_api.database import Base, get_db +from shop_api.main import app + +# Utilise PostgreSQL en CI, SQLite en local +IS_CI = os.getenv('GITHUB_ACTIONS') == 'true' +TEST_DATABASE_URL = os.getenv("DATABASE_URL", + "postgresql://postgres:password@postgres:5432/test_db" if IS_CI + else "sqlite:///./test.db" +) + engine = create_engine(TEST_DATABASE_URL) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @@ -38,7 +33,7 @@ def db_session(): yield session finally: session.close() - if not IS_CI: # Ne drop que en local + if not IS_CI: Base.metadata.drop_all(bind=engine) @pytest.fixture(scope="function") From 3cb540618c17525b3910063f03d6544c61a7c58a Mon Sep 17 00:00:00 2001 From: sidoine Date: Mon, 20 Oct 2025 18:04:48 +0300 Subject: [PATCH 19/48] test: Trigger HW5 CI with corrected paths --- hw5/hw/tests/conftest.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/hw5/hw/tests/conftest.py b/hw5/hw/tests/conftest.py index 7a66762e..b0556c20 100644 --- a/hw5/hw/tests/conftest.py +++ b/hw5/hw/tests/conftest.py @@ -2,18 +2,22 @@ import sys import os -# Ajoute le répertoire parent au chemin Python -current_dir = os.path.dirname(os.path.abspath(__file__)) -project_root = os.path.dirname(current_dir) -sys.path.insert(0, project_root) +# Ajoute le répertoire courant au chemin Python +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from fastapi.testclient import TestClient -# Import depuis shop_api (votre structure correcte) -from shop_api.database import Base, get_db -from shop_api.main import app +# Import DEPUIS LA RACINE (hw5/hw) +try: + from database import Base, get_db + from main import app +except ImportError as e: + print(f"Import error: {e}") + print(f"Current directory: {os.getcwd()}") + print(f"Python path: {sys.path}") + raise # Utilise PostgreSQL en CI, SQLite en local IS_CI = os.getenv('GITHUB_ACTIONS') == 'true' From 01422194929aaa8f6351cce46a0df23b6bd0cfa8 Mon Sep 17 00:00:00 2001 From: sidoine Date: Mon, 20 Oct 2025 18:09:27 +0300 Subject: [PATCH 20/48] test: Trigger HW5 CI with corrected paths --- hw5/hw/tests/conftest.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/hw5/hw/tests/conftest.py b/hw5/hw/tests/conftest.py index b0556c20..5432adb8 100644 --- a/hw5/hw/tests/conftest.py +++ b/hw5/hw/tests/conftest.py @@ -2,21 +2,28 @@ import sys import os -# Ajoute le répertoire courant au chemin Python -sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +# Chemin ABSOLU vers la racine du projet hw5/hw +project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, project_root) + +print(f"Project root: {project_root}") +print(f"Files in root: {os.listdir(project_root)}") from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from fastapi.testclient import TestClient -# Import DEPUIS LA RACINE (hw5/hw) +# Import depuis la racine try: from database import Base, get_db from main import app + print("✅ Successfully imported from root") except ImportError as e: - print(f"Import error: {e}") - print(f"Current directory: {os.getcwd()}") - print(f"Python path: {sys.path}") + print(f"❌ Import error: {e}") + # Liste tous les fichiers pour debug + print("Files in directory:") + for file in os.listdir(project_root): + print(f" - {file}") raise # Utilise PostgreSQL en CI, SQLite en local @@ -26,6 +33,8 @@ else "sqlite:///./test.db" ) +print(f"Database URL: {TEST_DATABASE_URL}") + engine = create_engine(TEST_DATABASE_URL) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) From 376ba10c2afad7ed039164ed773340b458492ec2 Mon Sep 17 00:00:00 2001 From: sidoine Date: Mon, 20 Oct 2025 18:12:48 +0300 Subject: [PATCH 21/48] test: Trigger HW5 CI with corrected paths --- hw5/hw/tests/conftest.py | 46 +++++++++++++++++----------------------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/hw5/hw/tests/conftest.py b/hw5/hw/tests/conftest.py index 5432adb8..12278625 100644 --- a/hw5/hw/tests/conftest.py +++ b/hw5/hw/tests/conftest.py @@ -2,39 +2,35 @@ import sys import os -# Chemin ABSOLU vers la racine du projet hw5/hw -project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +# Remonter d'un niveau depuis tests/ pour arriver à hw5/hw/ +project_root = os.path.dirname(os.path.abspath(__file__)) sys.path.insert(0, project_root) -print(f"Project root: {project_root}") -print(f"Files in root: {os.listdir(project_root)}") - from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from fastapi.testclient import TestClient -# Import depuis la racine +# Import avec chemin relatif try: - from database import Base, get_db - from main import app - print("✅ Successfully imported from root") -except ImportError as e: - print(f"❌ Import error: {e}") - # Liste tous les fichiers pour debug - print("Files in directory:") - for file in os.listdir(project_root): - print(f" - {file}") + # Essayer d'importer depuis le répertoire parent + import importlib.util + spec = importlib.util.spec_from_file_location("database", os.path.join(project_root, "database.py")) + database = importlib.util.module_from_spec(spec) + spec.loader.exec_module(database) + Base = database.Base + get_db = database.get_db + + spec = importlib.util.spec_from_file_location("main", os.path.join(project_root, "main.py")) + main = importlib.util.module_from_spec(spec) + spec.loader.exec_module(main) + app = main.app + +except Exception as e: + print(f"Import error: {e}") raise -# Utilise PostgreSQL en CI, SQLite en local -IS_CI = os.getenv('GITHUB_ACTIONS') == 'true' -TEST_DATABASE_URL = os.getenv("DATABASE_URL", - "postgresql://postgres:password@postgres:5432/test_db" if IS_CI - else "sqlite:///./test.db" -) - -print(f"Database URL: {TEST_DATABASE_URL}") - +# Le reste du code reste identique... +TEST_DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:password@postgres:5432/test_db") engine = create_engine(TEST_DATABASE_URL) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @@ -46,8 +42,6 @@ def db_session(): yield session finally: session.close() - if not IS_CI: - Base.metadata.drop_all(bind=engine) @pytest.fixture(scope="function") def client(db_session): From 06c3b3946fdbfa61b3d0d9821af222828c0ee423 Mon Sep 17 00:00:00 2001 From: sidoine Date: Mon, 20 Oct 2025 18:15:23 +0300 Subject: [PATCH 22/48] test: Trigger HW5 CI with corrected paths --- hw5/hw/setup.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 hw5/hw/setup.py diff --git a/hw5/hw/setup.py b/hw5/hw/setup.py new file mode 100644 index 00000000..042c6377 --- /dev/null +++ b/hw5/hw/setup.py @@ -0,0 +1,13 @@ +from setuptools import setup, find_packages + +setup( + name="shop_api", + version="0.1.0", + packages=find_packages(), + install_requires=[ + "fastapi>=0.104.0", + "sqlalchemy>=2.0.0", + "psycopg2-binary>=2.9.0", + "pydantic>=2.5.0", + ], +) \ No newline at end of file From ecf0a83ea23dd49baa5dbfa4e277cb6dcb45802b Mon Sep 17 00:00:00 2001 From: sidoine Date: Mon, 20 Oct 2025 18:19:51 +0300 Subject: [PATCH 23/48] test: Trigger HW5 CI with corrected paths --- .github/workflows/h5-tests.yml | 26 +++++++++++++++----------- hw5/hw/setup.py | 6 ------ hw5/hw/tests/conftest.py | 29 ++++------------------------- 3 files changed, 19 insertions(+), 42 deletions(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index a8aff16c..6ad4bae7 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -11,11 +11,10 @@ on: jobs: test-hw5: runs-on: ubuntu-latest - # ✅ Supprimez la matrix pour tester avec 3.12 uniquement - # strategy: - # matrix: - # python-version: ["3.12", "3.13"] - + strategy: + matrix: + python-version: ["3.12"] # Testons d'abord avec une seule version + services: postgres: image: postgres:15 @@ -34,25 +33,30 @@ jobs: steps: - name: Checkout code uses: actions/checkout@v4 - - - name: Set up Python + + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: - python-version: "3.12" # ✅ Version fixe - + python-version: ${{ matrix.python-version }} + - name: Install dependencies working-directory: hw5/hw run: | python -m pip install --upgrade pip pip install -r requirements-test.txt - + + - name: Install local package + working-directory: hw5/hw + run: | + pip install -e . + - name: Run tests with coverage working-directory: hw5/hw env: DATABASE_URL: postgresql://postgres:password@postgres:5432/test_db run: | pytest --cov=shop_api --cov-report=xml --cov-report=term-missing - + - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: diff --git a/hw5/hw/setup.py b/hw5/hw/setup.py index 042c6377..dbc807c3 100644 --- a/hw5/hw/setup.py +++ b/hw5/hw/setup.py @@ -4,10 +4,4 @@ name="shop_api", version="0.1.0", packages=find_packages(), - install_requires=[ - "fastapi>=0.104.0", - "sqlalchemy>=2.0.0", - "psycopg2-binary>=2.9.0", - "pydantic>=2.5.0", - ], ) \ No newline at end of file diff --git a/hw5/hw/tests/conftest.py b/hw5/hw/tests/conftest.py index 12278625..b6090637 100644 --- a/hw5/hw/tests/conftest.py +++ b/hw5/hw/tests/conftest.py @@ -1,36 +1,15 @@ import pytest -import sys import os - -# Remonter d'un niveau depuis tests/ pour arriver à hw5/hw/ -project_root = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, project_root) - from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from fastapi.testclient import TestClient -# Import avec chemin relatif -try: - # Essayer d'importer depuis le répertoire parent - import importlib.util - spec = importlib.util.spec_from_file_location("database", os.path.join(project_root, "database.py")) - database = importlib.util.module_from_spec(spec) - spec.loader.exec_module(database) - Base = database.Base - get_db = database.get_db - - spec = importlib.util.spec_from_file_location("main", os.path.join(project_root, "main.py")) - main = importlib.util.module_from_spec(spec) - spec.loader.exec_module(main) - app = main.app - -except Exception as e: - print(f"Import error: {e}") - raise +# Maintenant que le package est installé, les imports devraient marcher +from database import Base, get_db +from main import app -# Le reste du code reste identique... TEST_DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:password@postgres:5432/test_db") + engine = create_engine(TEST_DATABASE_URL) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) From 1deaad98603a2edb275e07980778e64d71a0ec45 Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 00:00:14 +0300 Subject: [PATCH 24/48] test: Trigger HW5 CI with corrected paths --- .github/workflows/h5-tests.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index 6ad4bae7..4b5113a9 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.12"] # Testons d'abord avec une seule version + python-version: ["3.12"] services: postgres: @@ -45,16 +45,21 @@ jobs: python -m pip install --upgrade pip pip install -r requirements-test.txt - - name: Install local package + - name: Set Python path working-directory: hw5/hw run: | - pip install -e . + echo "PYTHONPATH=$(pwd)" >> $GITHUB_ENV + echo "Current directory: $(pwd)" + echo "Files in directory:" + ls -la - name: Run tests with coverage working-directory: hw5/hw env: DATABASE_URL: postgresql://postgres:password@postgres:5432/test_db + PYTHONPATH: ${{ github.workspace }}/hw5/hw run: | + python -c "import sys; print('Python path:', sys.path)" pytest --cov=shop_api --cov-report=xml --cov-report=term-missing - name: Upload coverage to Codecov From 233145408bcf79f7b4b7a20291b49053727be840 Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 00:57:50 +0300 Subject: [PATCH 25/48] test: Trigger HW5 CI with corrected paths --- .github/workflows/h5-tests.yml | 13 +++----- hw5/hw/tests/conftest.py | 61 +++++++++++++++++++++++++++++++--- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index 4b5113a9..e5037d5d 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.12"] + python-version: ["3.12"] # Testons d'abord avec une seule version services: postgres: @@ -45,22 +45,17 @@ jobs: python -m pip install --upgrade pip pip install -r requirements-test.txt - - name: Set Python path + - name: Install local package working-directory: hw5/hw run: | - echo "PYTHONPATH=$(pwd)" >> $GITHUB_ENV - echo "Current directory: $(pwd)" - echo "Files in directory:" - ls -la + pip install -e . - name: Run tests with coverage working-directory: hw5/hw env: DATABASE_URL: postgresql://postgres:password@postgres:5432/test_db - PYTHONPATH: ${{ github.workspace }}/hw5/hw run: | - python -c "import sys; print('Python path:', sys.path)" - pytest --cov=shop_api --cov-report=xml --cov-report=term-missing + pytest --cov=shop_api --cov-report=xml --cov-report=term-missing -v - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 diff --git a/hw5/hw/tests/conftest.py b/hw5/hw/tests/conftest.py index b6090637..55e63fd6 100644 --- a/hw5/hw/tests/conftest.py +++ b/hw5/hw/tests/conftest.py @@ -1,26 +1,78 @@ import pytest +import sys import os +import importlib.util + +# Chemin absolu vers la racine du projet +current_file = os.path.abspath(__file__) +tests_dir = os.path.dirname(current_file) +project_root = os.path.dirname(tests_dir) + +print(f"🔍 DEBUG INFO:") +print(f"Current file: {current_file}") +print(f"Tests dir: {tests_dir}") +print(f"Project root: {project_root}") +print(f"Files in project root: {os.listdir(project_root)}") + +# Ajouter explicitement au path +sys.path.insert(0, project_root) +print(f"Python path: {sys.path}") + from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from fastapi.testclient import TestClient -# Maintenant que le package est installé, les imports devraient marcher -from database import Base, get_db -from main import app +# IMPORT FORCÉ - méthode robuste +def import_module(module_name, file_path): + """Importe un module depuis un chemin absolu""" + spec = importlib.util.spec_from_file_location(module_name, file_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + +# Importer database.py +database_path = os.path.join(project_root, "database.py") +print(f"Database path: {database_path}") +print(f"Database exists: {os.path.exists(database_path)}") + +if os.path.exists(database_path): + database = import_module("database", database_path) + Base = database.Base + get_db = database.get_db + print("✅ Database imported successfully") +else: + raise FileNotFoundError(f"database.py not found at {database_path}") + +# Importer main.py +main_path = os.path.join(project_root, "main.py") +print(f"Main path: {main_path}") +print(f"Main exists: {os.path.exists(main_path)}") + +if os.path.exists(main_path): + main = import_module("main", main_path) + app = main.app + print("✅ Main imported successfully") +else: + raise FileNotFoundError(f"main.py not found at {main_path}") +# Configuration de la base de données TEST_DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:password@postgres:5432/test_db") +print(f"Using database: {TEST_DATABASE_URL}") engine = create_engine(TEST_DATABASE_URL) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @pytest.fixture(scope="function") def db_session(): + print("🔄 Creating database tables...") Base.metadata.create_all(bind=engine) session = TestingSessionLocal() try: yield session + print("✅ Database session completed") finally: session.close() + print("🔚 Database session closed") @pytest.fixture(scope="function") def client(db_session): @@ -30,4 +82,5 @@ def override_get_db(): app.dependency_overrides[get_db] = override_get_db client = TestClient(app) yield client - app.dependency_overrides.clear() \ No newline at end of file + app.dependency_overrides.clear() + print("🔚 Test client cleaned up") \ No newline at end of file From f17b7bbe21fc4f0ccb6d58c48c01e8fe73beb72b Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 01:20:19 +0300 Subject: [PATCH 26/48] test: Trigger HW5 CI with corrected paths1 --- hw5/hw/tests/conftest copy.py | 44 +++++++++++++++++++++++ hw5/hw/tests/conftest.py | 68 +++++++---------------------------- 2 files changed, 57 insertions(+), 55 deletions(-) create mode 100644 hw5/hw/tests/conftest copy.py diff --git a/hw5/hw/tests/conftest copy.py b/hw5/hw/tests/conftest copy.py new file mode 100644 index 00000000..5a292c94 --- /dev/null +++ b/hw5/hw/tests/conftest copy.py @@ -0,0 +1,44 @@ +import pytest +import sys +import os + +# AJOUTEZ CETTE LIGNE MANQUANTE +sys.path.append('/app') + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from fastapi.testclient import TestClient + +from database import Base, get_db +from main import app + +# Utilise SQLite en local, PostgreSQL sur GitHub +IS_CI = os.getenv('GITHUB_ACTIONS') == 'true' +TEST_DATABASE_URL = os.getenv("DATABASE_URL", + "postgresql://postgres:password@postgres:5432/test_db" if IS_CI + else "sqlite:///./test.db" +) + +engine = create_engine(TEST_DATABASE_URL) +TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +@pytest.fixture(scope="function") +def db_session(): + Base.metadata.create_all(bind=engine) + session = TestingSessionLocal() + try: + yield session + finally: + session.close() + if not IS_CI: # Ne supprime les tables qu'en local + Base.metadata.drop_all(bind=engine) + +@pytest.fixture(scope="function") +def client(db_session): + def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + client = TestClient(app) + yield client + app.dependency_overrides.clear() \ No newline at end of file diff --git a/hw5/hw/tests/conftest.py b/hw5/hw/tests/conftest.py index 55e63fd6..5a292c94 100644 --- a/hw5/hw/tests/conftest.py +++ b/hw5/hw/tests/conftest.py @@ -1,78 +1,37 @@ import pytest import sys import os -import importlib.util -# Chemin absolu vers la racine du projet -current_file = os.path.abspath(__file__) -tests_dir = os.path.dirname(current_file) -project_root = os.path.dirname(tests_dir) - -print(f"🔍 DEBUG INFO:") -print(f"Current file: {current_file}") -print(f"Tests dir: {tests_dir}") -print(f"Project root: {project_root}") -print(f"Files in project root: {os.listdir(project_root)}") - -# Ajouter explicitement au path -sys.path.insert(0, project_root) -print(f"Python path: {sys.path}") +# AJOUTEZ CETTE LIGNE MANQUANTE +sys.path.append('/app') from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from fastapi.testclient import TestClient -# IMPORT FORCÉ - méthode robuste -def import_module(module_name, file_path): - """Importe un module depuis un chemin absolu""" - spec = importlib.util.spec_from_file_location(module_name, file_path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - -# Importer database.py -database_path = os.path.join(project_root, "database.py") -print(f"Database path: {database_path}") -print(f"Database exists: {os.path.exists(database_path)}") - -if os.path.exists(database_path): - database = import_module("database", database_path) - Base = database.Base - get_db = database.get_db - print("✅ Database imported successfully") -else: - raise FileNotFoundError(f"database.py not found at {database_path}") - -# Importer main.py -main_path = os.path.join(project_root, "main.py") -print(f"Main path: {main_path}") -print(f"Main exists: {os.path.exists(main_path)}") - -if os.path.exists(main_path): - main = import_module("main", main_path) - app = main.app - print("✅ Main imported successfully") -else: - raise FileNotFoundError(f"main.py not found at {main_path}") +from database import Base, get_db +from main import app -# Configuration de la base de données -TEST_DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:password@postgres:5432/test_db") -print(f"Using database: {TEST_DATABASE_URL}") +# Utilise SQLite en local, PostgreSQL sur GitHub +IS_CI = os.getenv('GITHUB_ACTIONS') == 'true' +TEST_DATABASE_URL = os.getenv("DATABASE_URL", + "postgresql://postgres:password@postgres:5432/test_db" if IS_CI + else "sqlite:///./test.db" +) engine = create_engine(TEST_DATABASE_URL) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @pytest.fixture(scope="function") def db_session(): - print("🔄 Creating database tables...") Base.metadata.create_all(bind=engine) session = TestingSessionLocal() try: yield session - print("✅ Database session completed") finally: session.close() - print("🔚 Database session closed") + if not IS_CI: # Ne supprime les tables qu'en local + Base.metadata.drop_all(bind=engine) @pytest.fixture(scope="function") def client(db_session): @@ -82,5 +41,4 @@ def override_get_db(): app.dependency_overrides[get_db] = override_get_db client = TestClient(app) yield client - app.dependency_overrides.clear() - print("🔚 Test client cleaned up") \ No newline at end of file + app.dependency_overrides.clear() \ No newline at end of file From 8074aab1b9be1a038374b41d00ce8bb6c1bcf9b9 Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 01:32:36 +0300 Subject: [PATCH 27/48] test: Trigger HW5 CI with corrected paths2 --- .github/workflows/h5-tests.yml | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index e5037d5d..08f0f112 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -2,10 +2,10 @@ name: "HW5 Tests" on: pull_request: - branches: [ main, master ] + branches: [ main ] paths: [ 'hw5/hw/**' ] push: - branches: [ main, master ] + branches: [ main ] paths: [ 'hw5/hw/**' ] jobs: @@ -13,7 +13,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.12"] # Testons d'abord avec une seule version + python-version: ["3.12"] services: postgres: @@ -34,6 +34,20 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + # 🔎 Étape de diagnostic : afficher l’arborescence complète + - name: Afficher le répertoire courant et son contenu + run: | + echo "Répertoire racine du dépôt :" + pwd + ls -R hw5 || true + + # 🧭 Définir le PYTHONPATH sur le bon dossier + - name: Définir le PYTHONPATH + run: | + echo "Définition du PYTHONPATH..." + echo "PYTHONPATH=${{ github.workspace }}/hw5/hw" >> $GITHUB_ENV + echo "PYTHONPATH défini sur : ${{ github.workspace }}/hw5/hw" + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: @@ -45,16 +59,21 @@ jobs: python -m pip install --upgrade pip pip install -r requirements-test.txt + # ⚙️ Installation du package local (facultatif, mais utile si setup.py/pyproject.toml existe) - name: Install local package working-directory: hw5/hw run: | pip install -e . + # 🧪 Lancement des tests avec couverture - name: Run tests with coverage working-directory: hw5/hw env: DATABASE_URL: postgresql://postgres:password@postgres:5432/test_db + PYTHONPATH: ${{ github.workspace }}/hw5/hw run: | + echo "Chemins utilisés par Python :" + python -c "import sys; print(sys.path)" pytest --cov=shop_api --cov-report=xml --cov-report=term-missing -v - name: Upload coverage to Codecov @@ -67,4 +86,4 @@ jobs: - name: Check 95% coverage threshold working-directory: hw5/hw run: | - python -m coverage report --fail-under=95 \ No newline at end of file + python -m coverage report --fail-under=95 From f2a302bf76a781a1f30bdbab2532e3f86a6f484f Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 01:40:53 +0300 Subject: [PATCH 28/48] test: Trigger HW5 CI with corrected paths2 --- .github/workflows/h5-tests.yml | 43 ++++++++++++++++++--------------- hw5/hw/test.db | Bin 28672 -> 28672 bytes 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index 08f0f112..3e7159da 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -34,19 +34,15 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - # 🔎 Étape de diagnostic : afficher l’arborescence complète - - name: Afficher le répertoire courant et son contenu + # 🔎 Étape de diagnostic améliorée + - name: Afficher la structure du projet run: | - echo "Répertoire racine du dépôt :" + echo "=== Structure du projet ===" pwd - ls -R hw5 || true - - # 🧭 Définir le PYTHONPATH sur le bon dossier - - name: Définir le PYTHONPATH - run: | - echo "Définition du PYTHONPATH..." - echo "PYTHONPATH=${{ github.workspace }}/hw5/hw" >> $GITHUB_ENV - echo "PYTHONPATH défini sur : ${{ github.workspace }}/hw5/hw" + echo "=== Contenu de hw5/hw ===" + ls -la hw5/hw/ || echo "hw5/hw/ non trouvé" + echo "=== Fichier database.py existe ? ===" + ls -la hw5/hw/database.py || echo "database.py non trouvé" - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 @@ -59,21 +55,30 @@ jobs: python -m pip install --upgrade pip pip install -r requirements-test.txt - # ⚙️ Installation du package local (facultatif, mais utile si setup.py/pyproject.toml existe) - - name: Install local package + # 🎯 CORRECTION : Définir PYTHONPATH APRÈS l'installation + - name: Définir PYTHONPATH working-directory: hw5/hw run: | - pip install -e . + echo "PYTHONPATH=$(pwd)" >> $GITHUB_ENV + echo "PYTHONPATH défini sur: $(pwd)" - # 🧪 Lancement des tests avec couverture + # 🧪 Lancement des tests avec diagnostic - name: Run tests with coverage working-directory: hw5/hw env: DATABASE_URL: postgresql://postgres:password@postgres:5432/test_db - PYTHONPATH: ${{ github.workspace }}/hw5/hw + # PYTHONPATH est déjà défini dans l'étape précédente run: | - echo "Chemins utilisés par Python :" - python -c "import sys; print(sys.path)" + echo "=== Diagnostic avant tests ===" + echo "Répertoire courant: $(pwd)" + echo "PYTHONPATH: $PYTHONPATH" + echo "Fichiers présents:" + ls -la + echo "=== Chemins Python ===" + python -c "import sys; print('\n'.join(sys.path))" + echo "=== Test d'import database ===" + python -c "from database import Base; print('✅ Import database réussi')" || echo "❌ Import database échoué" + echo "=== Lancement des tests ===" pytest --cov=shop_api --cov-report=xml --cov-report=term-missing -v - name: Upload coverage to Codecov @@ -86,4 +91,4 @@ jobs: - name: Check 95% coverage threshold working-directory: hw5/hw run: | - python -m coverage report --fail-under=95 + python -m coverage report --fail-under=95 \ No newline at end of file diff --git a/hw5/hw/test.db b/hw5/hw/test.db index 8ee0fb9ccdcf3250589ca445e34a2e557c2765fa..cbf3d30805b185f0d0bf786c0418e12c4a2a06b0 100644 GIT binary patch delta 35 lcmZp8z}WDBae}nq0R{#Jb|_|JVBq;VQOB6^z{Z3H`2e6Y2`K;o delta 35 lcmZp8z}WDBae}m9HUk3#I~21qFz|essAJ5Sy)j`yJ^+=C2zCGf From b23551b2c8f195335bc4012029c45a46bff93a35 Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 02:08:49 +0300 Subject: [PATCH 29/48] test: Trigger HW5 CI with corrected paths2 --- .github/workflows/h5-tests.yml | 57 ++++++++++++++++------------------ hw5/hw/tests/conftest.py | 8 ++--- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index 3e7159da..e4bc2007 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -11,9 +11,6 @@ on: jobs: test-hw5: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12"] services: postgres: @@ -34,20 +31,10 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - # 🔎 Étape de diagnostic améliorée - - name: Afficher la structure du projet - run: | - echo "=== Structure du projet ===" - pwd - echo "=== Contenu de hw5/hw ===" - ls -la hw5/hw/ || echo "hw5/hw/ non trouvé" - echo "=== Fichier database.py existe ? ===" - ls -la hw5/hw/database.py || echo "database.py non trouvé" - - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python uses: actions/setup-python@v4 with: - python-version: ${{ matrix.python-version }} + python-version: "3.12" - name: Install dependencies working-directory: hw5/hw @@ -55,31 +42,39 @@ jobs: python -m pip install --upgrade pip pip install -r requirements-test.txt - # 🎯 CORRECTION : Définir PYTHONPATH APRÈS l'installation - - name: Définir PYTHONPATH + - name: Debug project structure working-directory: hw5/hw run: | - echo "PYTHONPATH=$(pwd)" >> $GITHUB_ENV - echo "PYTHONPATH défini sur: $(pwd)" + echo "=== Structure du projet ===" + pwd + ls -la + echo "=== Test présence database.py ===" + ls -la database.py + echo "=== Test import manuel ===" + python -c " + import sys + sys.path.append('.') + try: + from database import Base + print('✅ SUCCÈS: database.py peut être importé') + except Exception as e: + print(f'❌ ÉCHEC: {e}') + " - # 🧪 Lancement des tests avec diagnostic - name: Run tests with coverage working-directory: hw5/hw env: DATABASE_URL: postgresql://postgres:password@postgres:5432/test_db - # PYTHONPATH est déjà défini dans l'étape précédente run: | - echo "=== Diagnostic avant tests ===" - echo "Répertoire courant: $(pwd)" + # Forcer l'ajout du chemin courant à Python + export PYTHONPATH=$PYTHONPATH:$(pwd) echo "PYTHONPATH: $PYTHONPATH" - echo "Fichiers présents:" - ls -la - echo "=== Chemins Python ===" - python -c "import sys; print('\n'.join(sys.path))" - echo "=== Test d'import database ===" - python -c "from database import Base; print('✅ Import database réussi')" || echo "❌ Import database échoué" - echo "=== Lancement des tests ===" - pytest --cov=shop_api --cov-report=xml --cov-report=term-missing -v + + # Test final avant les tests + python -c "from database import Base; print('🎉 Import database RÉUSSI!'); from main import app; print('🎉 Import main RÉUSSI!')" + + # Lancement des tests + pytest --cov=shop_api --cov-report=xml --cov-report=term-missing - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 diff --git a/hw5/hw/tests/conftest.py b/hw5/hw/tests/conftest.py index 5a292c94..0bd9aaba 100644 --- a/hw5/hw/tests/conftest.py +++ b/hw5/hw/tests/conftest.py @@ -2,8 +2,8 @@ import sys import os -# AJOUTEZ CETTE LIGNE MANQUANTE -sys.path.append('/app') +# Solution universelle - ajoute le chemin courant +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @@ -12,7 +12,7 @@ from database import Base, get_db from main import app -# Utilise SQLite en local, PostgreSQL sur GitHub +# Configuration base de données IS_CI = os.getenv('GITHUB_ACTIONS') == 'true' TEST_DATABASE_URL = os.getenv("DATABASE_URL", "postgresql://postgres:password@postgres:5432/test_db" if IS_CI @@ -30,7 +30,7 @@ def db_session(): yield session finally: session.close() - if not IS_CI: # Ne supprime les tables qu'en local + if not IS_CI: Base.metadata.drop_all(bind=engine) @pytest.fixture(scope="function") From 257f37b9b458c0baa9a5841e6b438a5b5dd02de1 Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 02:55:58 +0300 Subject: [PATCH 30/48] test: Trigger HW5 CI with corrected paths3 --- .github/workflows/h5-tests.yml | 41 ++++++++++++++++++---------------- 1 file changed, 22 insertions(+), 19 deletions(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index e4bc2007..b019579e 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -36,44 +36,47 @@ jobs: with: python-version: "3.12" + - name: Wait for PostgreSQL to be ready + run: | + echo "Waiting for PostgreSQL to be ready..." + for i in {1..30}; do + nc -z localhost 5432 && echo "PostgreSQL is ready!" && break + echo "Waiting for PostgreSQL... attempt $i" + sleep 2 + done + - name: Install dependencies working-directory: hw5/hw run: | python -m pip install --upgrade pip pip install -r requirements-test.txt - - name: Debug project structure + - name: Test database connection working-directory: hw5/hw + env: + DATABASE_URL: postgresql://postgres:password@localhost:5432/test_db run: | - echo "=== Structure du projet ===" - pwd - ls -la - echo "=== Test présence database.py ===" - ls -la database.py - echo "=== Test import manuel ===" + echo "Testing database connection..." python -c " - import sys - sys.path.append('.') + import psycopg2 try: - from database import Base - print('✅ SUCCÈS: database.py peut être importé') + conn = psycopg2.connect('postgresql://postgres:password@localhost:5432/test_db') + print('✅ Database connection SUCCESSFUL') + conn.close() except Exception as e: - print(f'❌ ÉCHEC: {e}') + print(f'❌ Database connection FAILED: {e}') " - name: Run tests with coverage working-directory: hw5/hw env: - DATABASE_URL: postgresql://postgres:password@postgres:5432/test_db + DATABASE_URL: postgresql://postgres:password@localhost:5432/test_db run: | # Forcer l'ajout du chemin courant à Python export PYTHONPATH=$PYTHONPATH:$(pwd) - echo "PYTHONPATH: $PYTHONPATH" - - # Test final avant les tests - python -c "from database import Base; print('🎉 Import database RÉUSSI!'); from main import app; print('🎉 Import main RÉUSSI!')" - - # Lancement des tests + echo "Testing imports..." + python -c "from database import Base; from main import app; print('✅ All imports successful')" + echo "Running tests..." pytest --cov=shop_api --cov-report=xml --cov-report=term-missing - name: Upload coverage to Codecov From 14866a194abc04143fee8297ddb84cb07d3b69e8 Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 03:07:22 +0300 Subject: [PATCH 31/48] test: Trigger HW5 CI with corrected paths3 --- .github/workflows/h5-tests.yml | 89 ++++++++++++++++++++++------------ hw5/hw/tests/conftest.py | 31 ++++++++---- 2 files changed, 80 insertions(+), 40 deletions(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index b019579e..670804d0 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -11,6 +11,9 @@ on: jobs: test-hw5: runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] services: postgres: @@ -20,7 +23,7 @@ jobs: POSTGRES_PASSWORD: password POSTGRES_DB: test_db options: >- - --health-cmd pg_isready + --health-cmd "pg_isready -U postgres" --health-interval 10s --health-timeout 5s --health-retries 5 @@ -28,57 +31,80 @@ jobs: - 5432:5432 steps: + # 🧩 Étape 1 : Récupération du code source - name: Checkout code uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: "3.12" + # 🧠 Étape 2 : Affichage du répertoire courant + - name: Afficher le répertoire courant + run: | + pwd + ls -la - - name: Wait for PostgreSQL to be ready + # 🧠 Étape 3 : Définir le PYTHONPATH + - name: Set Python path + working-directory: hw5/hw run: | - echo "Waiting for PostgreSQL to be ready..." - for i in {1..30}; do - nc -z localhost 5432 && echo "PostgreSQL is ready!" && break - echo "Waiting for PostgreSQL... attempt $i" - sleep 2 - done + echo "Répertoire courant : $(pwd)" + ls -la + echo "PYTHONPATH=$(pwd)" >> $GITHUB_ENV + + # 🐍 Étape 4 : Installation de Python + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + # 📦 Étape 5 : Installation des dépendances - name: Install dependencies working-directory: hw5/hw run: | python -m pip install --upgrade pip pip install -r requirements-test.txt - - name: Test database connection + # 🏗️ Étape 6 : Installation du package local + - name: Install local package + working-directory: hw5/hw + run: | + pip install -e . + + # 🩺 Étape 7 : Diagnostic d'import + - name: Diagnostic imports working-directory: hw5/hw env: - DATABASE_URL: postgresql://postgres:password@localhost:5432/test_db + PYTHONPATH: ${{ github.workspace }}/hw5/hw run: | - echo "Testing database connection..." - python -c " - import psycopg2 - try: - conn = psycopg2.connect('postgresql://postgres:password@localhost:5432/test_db') - print('✅ Database connection SUCCESSFUL') - conn.close() - except Exception as e: - print(f'❌ Database connection FAILED: {e}') - " + echo "=== Test import direct ===" + python -c "import database; print('✅ database import ok')" + + # 🕓 Étape 8 : Attente du démarrage de PostgreSQL + - name: Wait for PostgreSQL + run: | + echo "⏳ Attente du démarrage de PostgreSQL..." + for i in {1..10}; do + nc -z localhost 5432 && echo "✅ PostgreSQL prêt !" && break + echo "PostgreSQL pas encore prêt, nouvelle tentative..." + sleep 2 + done + # 🧪 Étape 9 : Exécution des tests avec couverture - name: Run tests with coverage working-directory: hw5/hw env: DATABASE_URL: postgresql://postgres:password@localhost:5432/test_db run: | - # Forcer l'ajout du chemin courant à Python - export PYTHONPATH=$PYTHONPATH:$(pwd) - echo "Testing imports..." - python -c "from database import Base; from main import app; print('✅ All imports successful')" - echo "Running tests..." - pytest --cov=shop_api --cov-report=xml --cov-report=term-missing + echo "=== Diagnostic avant tests ===" + echo "Répertoire courant: $(pwd)" + echo "PYTHONPATH: $PYTHONPATH" + ls -la + echo "=== Chemins Python ===" + python -c "import sys; print('\n'.join(sys.path))" + echo "=== Test d'import database ===" + python -c "from database import Base; print('✅ Import database réussi')" || echo "❌ Import database échoué" + echo "=== Lancement des tests ===" + pytest --cov=shop_api --cov-report=xml --cov-report=term-missing -v + # 📤 Étape 10 : Upload du rapport de couverture - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: @@ -86,7 +112,8 @@ jobs: flags: unittests fail_ci_if_error: false + # ✅ Étape 11 : Vérification du seuil de couverture - name: Check 95% coverage threshold working-directory: hw5/hw run: | - python -m coverage report --fail-under=95 \ No newline at end of file + python -m coverage report --fail-under=95 diff --git a/hw5/hw/tests/conftest.py b/hw5/hw/tests/conftest.py index 0bd9aaba..20a3a047 100644 --- a/hw5/hw/tests/conftest.py +++ b/hw5/hw/tests/conftest.py @@ -2,8 +2,8 @@ import sys import os -# Solution universelle - ajoute le chemin courant -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +# 🧠 Ajout du chemin racine pour que les imports fonctionnent +sys.path.append('/app') from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker @@ -12,33 +12,46 @@ from database import Base, get_db from main import app -# Configuration base de données +# Détection de l'environnement CI (GitHub Actions) IS_CI = os.getenv('GITHUB_ACTIONS') == 'true' -TEST_DATABASE_URL = os.getenv("DATABASE_URL", - "postgresql://postgres:password@postgres:5432/test_db" if IS_CI - else "sqlite:///./test.db" -) +# 🛠 Configuration de la base de données selon l'environnement +if IS_CI: + # ✅ En CI : PostgreSQL (nom d’hôte = localhost) + TEST_DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql://postgres:password@localhost:5432/test_db" + ) +else: + # ✅ En local : SQLite + TEST_DATABASE_URL = "sqlite:///./test.db" + +# Création du moteur SQLAlchemy engine = create_engine(TEST_DATABASE_URL) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + @pytest.fixture(scope="function") def db_session(): + """Crée une session de base de données temporaire pour les tests""" Base.metadata.create_all(bind=engine) session = TestingSessionLocal() try: yield session finally: session.close() + # En local, on nettoie la base après chaque test if not IS_CI: Base.metadata.drop_all(bind=engine) + @pytest.fixture(scope="function") def client(db_session): + """Crée un client de test FastAPI en utilisant la session de test""" def override_get_db(): yield db_session - + app.dependency_overrides[get_db] = override_get_db client = TestClient(app) yield client - app.dependency_overrides.clear() \ No newline at end of file + app.dependency_overrides.clear() From 923571855a9da8ba6070cffadbe01f8967af7b3a Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 03:20:30 +0300 Subject: [PATCH 32/48] test: Trigger HW5 CI with corrected paths4 --- hw5/hw/test.db | Bin 28672 -> 28672 bytes hw5/hw/tests/conftest.py | 48 ++++++++++++++++++--------------------- 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/hw5/hw/test.db b/hw5/hw/test.db index cbf3d30805b185f0d0bf786c0418e12c4a2a06b0..a5acdca6f10f3e5415df9f76ae41579876e7335d 100644 GIT binary patch delta 37 ncmZp8z}WDBae|bP;0y)^26iZBV_@KGov35PBsgPZ!h(DNqE-lr delta 37 ncmZp8z}WDBae|Z({{aRD26iZBV_@L Date: Tue, 21 Oct 2025 03:36:48 +0300 Subject: [PATCH 33/48] test: Trigger HW5 CI with corrected paths5 --- .github/workflows/h5-tests.yml | 105 ++++++++++++--------------------- 1 file changed, 38 insertions(+), 67 deletions(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index 670804d0..f673a000 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -3,17 +3,41 @@ name: "HW5 Tests" on: pull_request: branches: [ main ] - paths: [ 'hw5/hw/**' ] + paths: + - 'hw5/hw/**' + - '.github/workflows/h5-tests.yml' push: branches: [ main ] - paths: [ 'hw5/hw/**' ] + paths: + - 'hw5/hw/**' + - '.github/workflows/h5-tests.yml' jobs: test-hw5: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.12"] + python-version: ["3.12", "3.13"] + + # ✅ Cache pour accélérer les builds + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' # ✅ Cache les dépendances + cache-dependency-path: 'hw5/hw/requirements-test.txt' + + - name: Cache pip packages + uses: actions/cache@v3 + with: + path: ~/.cache/pip + key: ${{ runner.os }}-pip-${{ hashFiles('hw5/hw/requirements-test.txt') }} + restore-keys: | + ${{ runner.os }}-pip- services: postgres: @@ -23,88 +47,36 @@ jobs: POSTGRES_PASSWORD: password POSTGRES_DB: test_db options: >- - --health-cmd "pg_isready -U postgres" + --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 ports: - 5432:5432 - steps: - # 🧩 Étape 1 : Récupération du code source - - name: Checkout code - uses: actions/checkout@v4 - - # 🧠 Étape 2 : Affichage du répertoire courant - - name: Afficher le répertoire courant + - name: Wait for PostgreSQL to be ready run: | - pwd - ls -la - - # 🧠 Étape 3 : Définir le PYTHONPATH - - name: Set Python path - working-directory: hw5/hw - run: | - echo "Répertoire courant : $(pwd)" - ls -la - echo "PYTHONPATH=$(pwd)" >> $GITHUB_ENV - - # 🐍 Étape 4 : Installation de Python - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} + echo "Waiting for PostgreSQL to be ready..." + for i in {1..30}; do + nc -z localhost 5432 && echo "PostgreSQL is ready!" && break + echo "Waiting for PostgreSQL... attempt $i" + sleep 2 + done - # 📦 Étape 5 : Installation des dépendances - name: Install dependencies working-directory: hw5/hw run: | python -m pip install --upgrade pip pip install -r requirements-test.txt - # 🏗️ Étape 6 : Installation du package local - - name: Install local package - working-directory: hw5/hw - run: | - pip install -e . - - # 🩺 Étape 7 : Diagnostic d'import - - name: Diagnostic imports - working-directory: hw5/hw - env: - PYTHONPATH: ${{ github.workspace }}/hw5/hw - run: | - echo "=== Test import direct ===" - python -c "import database; print('✅ database import ok')" - - # 🕓 Étape 8 : Attente du démarrage de PostgreSQL - - name: Wait for PostgreSQL - run: | - echo "⏳ Attente du démarrage de PostgreSQL..." - for i in {1..10}; do - nc -z localhost 5432 && echo "✅ PostgreSQL prêt !" && break - echo "PostgreSQL pas encore prêt, nouvelle tentative..." - sleep 2 - done - - # 🧪 Étape 9 : Exécution des tests avec couverture - name: Run tests with coverage working-directory: hw5/hw env: DATABASE_URL: postgresql://postgres:password@localhost:5432/test_db run: | - echo "=== Diagnostic avant tests ===" - echo "Répertoire courant: $(pwd)" - echo "PYTHONPATH: $PYTHONPATH" - ls -la - echo "=== Chemins Python ===" - python -c "import sys; print('\n'.join(sys.path))" - echo "=== Test d'import database ===" - python -c "from database import Base; print('✅ Import database réussi')" || echo "❌ Import database échoué" - echo "=== Lancement des tests ===" - pytest --cov=shop_api --cov-report=xml --cov-report=term-missing -v + export PYTHONPATH=$PYTHONPATH:$(pwd) + pytest --cov=shop_api --cov-report=xml --cov-report=term-missing - # 📤 Étape 10 : Upload du rapport de couverture - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 with: @@ -112,8 +84,7 @@ jobs: flags: unittests fail_ci_if_error: false - # ✅ Étape 11 : Vérification du seuil de couverture - name: Check 95% coverage threshold working-directory: hw5/hw run: | - python -m coverage report --fail-under=95 + python -m coverage report --fail-under=95 \ No newline at end of file From d975864928d11263e04e12eabed9d610b49dcd30 Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 03:38:18 +0300 Subject: [PATCH 34/48] test: Trigger HW5 CI with corrected paths5 --- .github/workflows/h5-tests.yml | 48 +++++++++++++++++++--------------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index f673a000..9f5806ce 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -17,27 +17,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.12", "3.13"] - - # ✅ Cache pour accélérer les builds - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - cache: 'pip' # ✅ Cache les dépendances - cache-dependency-path: 'hw5/hw/requirements-test.txt' - - - name: Cache pip packages - uses: actions/cache@v3 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('hw5/hw/requirements-test.txt') }} - restore-keys: | - ${{ runner.os }}-pip- + python-version: ["3.12", "3.13"] # ✅ Test avec les 2 versions services: postgres: @@ -54,6 +34,15 @@ jobs: ports: - 5432:5432 + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} # ✅ Version dynamique + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Wait for PostgreSQL to be ready run: | echo "Waiting for PostgreSQL to be ready..." @@ -69,12 +58,29 @@ jobs: python -m pip install --upgrade pip pip install -r requirements-test.txt + - name: Test database connection + working-directory: hw5/hw + env: + DATABASE_URL: postgresql://postgres:password@localhost:5432/test_db + run: | + echo "Testing database connection with Python ${{ matrix.python-version }}..." + python -c " + import psycopg2 + try: + conn = psycopg2.connect('postgresql://postgres:password@localhost:5432/test_db') + print('✅ Database connection SUCCESSFUL') + conn.close() + except Exception as e: + print(f'❌ Database connection FAILED: {e}') + " + - name: Run tests with coverage working-directory: hw5/hw env: DATABASE_URL: postgresql://postgres:password@localhost:5432/test_db run: | export PYTHONPATH=$PYTHONPATH:$(pwd) + echo "Running tests with Python ${{ matrix.python-version }}..." pytest --cov=shop_api --cov-report=xml --cov-report=term-missing - name: Upload coverage to Codecov From 495711a7e2cf8a70402a445d2d752a1723b8508f Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 03:42:06 +0300 Subject: [PATCH 35/48] test: Trigger HW5 CI with corrected paths5 --- .github/workflows/h5-tests.yml | 55 +++++++++++++++------------------- 1 file changed, 24 insertions(+), 31 deletions(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index 9f5806ce..e4bc2007 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -3,21 +3,14 @@ name: "HW5 Tests" on: pull_request: branches: [ main ] - paths: - - 'hw5/hw/**' - - '.github/workflows/h5-tests.yml' + paths: [ 'hw5/hw/**' ] push: branches: [ main ] - paths: - - 'hw5/hw/**' - - '.github/workflows/h5-tests.yml' + paths: [ 'hw5/hw/**' ] jobs: test-hw5: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12", "3.13"] # ✅ Test avec les 2 versions services: postgres: @@ -38,19 +31,10 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} # ✅ Version dynamique + - name: Set up Python uses: actions/setup-python@v4 with: - python-version: ${{ matrix.python-version }} - - - name: Wait for PostgreSQL to be ready - run: | - echo "Waiting for PostgreSQL to be ready..." - for i in {1..30}; do - nc -z localhost 5432 && echo "PostgreSQL is ready!" && break - echo "Waiting for PostgreSQL... attempt $i" - sleep 2 - done + python-version: "3.12" - name: Install dependencies working-directory: hw5/hw @@ -58,29 +42,38 @@ jobs: python -m pip install --upgrade pip pip install -r requirements-test.txt - - name: Test database connection + - name: Debug project structure working-directory: hw5/hw - env: - DATABASE_URL: postgresql://postgres:password@localhost:5432/test_db run: | - echo "Testing database connection with Python ${{ matrix.python-version }}..." + echo "=== Structure du projet ===" + pwd + ls -la + echo "=== Test présence database.py ===" + ls -la database.py + echo "=== Test import manuel ===" python -c " - import psycopg2 + import sys + sys.path.append('.') try: - conn = psycopg2.connect('postgresql://postgres:password@localhost:5432/test_db') - print('✅ Database connection SUCCESSFUL') - conn.close() + from database import Base + print('✅ SUCCÈS: database.py peut être importé') except Exception as e: - print(f'❌ Database connection FAILED: {e}') + print(f'❌ ÉCHEC: {e}') " - name: Run tests with coverage working-directory: hw5/hw env: - DATABASE_URL: postgresql://postgres:password@localhost:5432/test_db + DATABASE_URL: postgresql://postgres:password@postgres:5432/test_db run: | + # Forcer l'ajout du chemin courant à Python export PYTHONPATH=$PYTHONPATH:$(pwd) - echo "Running tests with Python ${{ matrix.python-version }}..." + echo "PYTHONPATH: $PYTHONPATH" + + # Test final avant les tests + python -c "from database import Base; print('🎉 Import database RÉUSSI!'); from main import app; print('🎉 Import main RÉUSSI!')" + + # Lancement des tests pytest --cov=shop_api --cov-report=xml --cov-report=term-missing - name: Upload coverage to Codecov From 2f8361720c4b9a450ad173ad65dd7d3ec7971f36 Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 03:43:32 +0300 Subject: [PATCH 36/48] test: Trigger HW5 CI with corrected paths6 --- .github/workflows/h5-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index e4bc2007..f1d13d61 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -56,7 +56,7 @@ jobs: sys.path.append('.') try: from database import Base - print('✅ SUCCÈS: database.py peut être importé') + print('SUCCÈS: database.py peut être importé') except Exception as e: print(f'❌ ÉCHEC: {e}') " From e47a2ae852275a8cba3d766d7399e1531bad3b03 Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 03:49:31 +0300 Subject: [PATCH 37/48] test: Trigger HW5 CI with corrected paths6 --- .github/workflows/h5-tests.yml | 87 ++++++++++++++++++++++++---------- 1 file changed, 61 insertions(+), 26 deletions(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index f1d13d61..a78f3ad0 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -11,6 +11,9 @@ on: jobs: test-hw5: runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12"] services: postgres: @@ -31,10 +34,20 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Set up Python + # 🔎 Étape de diagnostic améliorée + - name: Afficher la structure du projet + run: | + echo "=== Structure du projet ===" + pwd + echo "=== Contenu de hw5/hw ===" + ls -la hw5/hw/ || echo "hw5/hw/ non trouvé" + echo "=== Fichier database.py existe ? ===" + ls -la hw5/hw/database.py || echo "database.py non trouvé" + + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} - name: Install dependencies working-directory: hw5/hw @@ -42,39 +55,61 @@ jobs: python -m pip install --upgrade pip pip install -r requirements-test.txt - - name: Debug project structure + # 🎯 CORRECTION : Définir PYTHONPATH APRÈS l'installation + - name: Définir PYTHONPATH working-directory: hw5/hw run: | - echo "=== Structure du projet ===" - pwd - ls -la - echo "=== Test présence database.py ===" - ls -la database.py - echo "=== Test import manuel ===" - python -c " - import sys - sys.path.append('.') - try: - from database import Base - print('SUCCÈS: database.py peut être importé') - except Exception as e: - print(f'❌ ÉCHEC: {e}') - " + echo "PYTHONPATH=$(pwd)" >> $GITHUB_ENV + echo "PYTHONPATH défini sur: $(pwd)" + # 🧪 Lancement des tests avec diagnostic - name: Run tests with coverage working-directory: hw5/hw env: DATABASE_URL: postgresql://postgres:password@postgres:5432/test_db + # PYTHONPATH est déjà défini dans l'étape précédente run: | - # Forcer l'ajout du chemin courant à Python - export PYTHONPATH=$PYTHONPATH:$(pwd) + echo "=== Diagnostic avant tests ===" + echo "Répertoire courant: $(pwd)" echo "PYTHONPATH: $PYTHONPATH" - - # Test final avant les tests - python -c "from database import Base; print('🎉 Import database RÉUSSI!'); from main import app; print('🎉 Import main RÉUSSI!')" - - # Lancement des tests - pytest --cov=shop_api --cov-report=xml --cov-report=term-missing + echo "Fichiers présents:" + ls -la + echo "=== Chemins Python ===" + python -c "import sys; print('\n'.join(sys.path))" + echo "=== Test d'import database ===" + python -c "from database import Base; print('✅ Import database réussi')" || echo "❌ Import database échoué" + echo "=== Lancement des tests ===" + pytest --cov=shop_api --cov-report=xml --cov-report=term-missing -v + - name: Run tests with coverage + working-directory: hw5/hw + env: + DATABASE_URL: postgresql://postgres:password@postgres:5432/test_db + # CORRECTION : PYTHONPATH doit inclure les chemins système + votre projet + PYTHONPATH: /opt/hostedtoolcache/Python/3.12.11/x64/lib/python3.12/site-packages:/home/runner/work/python-backend-hw/python-backend-hw/hw5/hw + run: | + echo "=== Diagnostic avant tests ===" + echo "Répertoire courant: $(pwd)" + echo "PYTHONPATH: $PYTHONPATH" + echo "=== Test d'import database ===" + python -c " + import sys + print('Chemins Python:') + for path in sys.path: + print(f' - {path}') + try: + from database import Base + print('✅ Import database RÉUSSI') + except ImportError as e: + print(f'❌ Import database ÉCHOUÉ: {e}') + # Essayer l'import absolu + import importlib.util + spec = importlib.util.spec_from_file_location('database', './database.py') + database = importlib.util.module_from_spec(spec) + spec.loader.exec_module(database) + print('✅ Import absolu database RÉUSSI') + " + echo "=== Lancement des tests ===" + pytest --cov=shop_api --cov-report=xml --cov-report=term-missing -v - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 From 035163847362eef02e4c592c89b2f9e3fe615447 Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 03:52:55 +0300 Subject: [PATCH 38/48] test: Trigger HW5 CI with corrected paths6 --- .github/workflows/h5-tests.yml | 87 ++++++++++------------------------ 1 file changed, 26 insertions(+), 61 deletions(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index a78f3ad0..e4bc2007 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -11,9 +11,6 @@ on: jobs: test-hw5: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12"] services: postgres: @@ -34,20 +31,10 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - # 🔎 Étape de diagnostic améliorée - - name: Afficher la structure du projet - run: | - echo "=== Structure du projet ===" - pwd - echo "=== Contenu de hw5/hw ===" - ls -la hw5/hw/ || echo "hw5/hw/ non trouvé" - echo "=== Fichier database.py existe ? ===" - ls -la hw5/hw/database.py || echo "database.py non trouvé" - - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python uses: actions/setup-python@v4 with: - python-version: ${{ matrix.python-version }} + python-version: "3.12" - name: Install dependencies working-directory: hw5/hw @@ -55,61 +42,39 @@ jobs: python -m pip install --upgrade pip pip install -r requirements-test.txt - # 🎯 CORRECTION : Définir PYTHONPATH APRÈS l'installation - - name: Définir PYTHONPATH + - name: Debug project structure working-directory: hw5/hw run: | - echo "PYTHONPATH=$(pwd)" >> $GITHUB_ENV - echo "PYTHONPATH défini sur: $(pwd)" + echo "=== Structure du projet ===" + pwd + ls -la + echo "=== Test présence database.py ===" + ls -la database.py + echo "=== Test import manuel ===" + python -c " + import sys + sys.path.append('.') + try: + from database import Base + print('✅ SUCCÈS: database.py peut être importé') + except Exception as e: + print(f'❌ ÉCHEC: {e}') + " - # 🧪 Lancement des tests avec diagnostic - name: Run tests with coverage working-directory: hw5/hw env: DATABASE_URL: postgresql://postgres:password@postgres:5432/test_db - # PYTHONPATH est déjà défini dans l'étape précédente run: | - echo "=== Diagnostic avant tests ===" - echo "Répertoire courant: $(pwd)" + # Forcer l'ajout du chemin courant à Python + export PYTHONPATH=$PYTHONPATH:$(pwd) echo "PYTHONPATH: $PYTHONPATH" - echo "Fichiers présents:" - ls -la - echo "=== Chemins Python ===" - python -c "import sys; print('\n'.join(sys.path))" - echo "=== Test d'import database ===" - python -c "from database import Base; print('✅ Import database réussi')" || echo "❌ Import database échoué" - echo "=== Lancement des tests ===" - pytest --cov=shop_api --cov-report=xml --cov-report=term-missing -v - - name: Run tests with coverage - working-directory: hw5/hw - env: - DATABASE_URL: postgresql://postgres:password@postgres:5432/test_db - # CORRECTION : PYTHONPATH doit inclure les chemins système + votre projet - PYTHONPATH: /opt/hostedtoolcache/Python/3.12.11/x64/lib/python3.12/site-packages:/home/runner/work/python-backend-hw/python-backend-hw/hw5/hw - run: | - echo "=== Diagnostic avant tests ===" - echo "Répertoire courant: $(pwd)" - echo "PYTHONPATH: $PYTHONPATH" - echo "=== Test d'import database ===" - python -c " - import sys - print('Chemins Python:') - for path in sys.path: - print(f' - {path}') - try: - from database import Base - print('✅ Import database RÉUSSI') - except ImportError as e: - print(f'❌ Import database ÉCHOUÉ: {e}') - # Essayer l'import absolu - import importlib.util - spec = importlib.util.spec_from_file_location('database', './database.py') - database = importlib.util.module_from_spec(spec) - spec.loader.exec_module(database) - print('✅ Import absolu database RÉUSSI') - " - echo "=== Lancement des tests ===" - pytest --cov=shop_api --cov-report=xml --cov-report=term-missing -v + + # Test final avant les tests + python -c "from database import Base; print('🎉 Import database RÉUSSI!'); from main import app; print('🎉 Import main RÉUSSI!')" + + # Lancement des tests + pytest --cov=shop_api --cov-report=xml --cov-report=term-missing - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 From fed7e7f8e8f48ca38c684154a92e7322356a9624 Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 04:02:05 +0300 Subject: [PATCH 39/48] test: Trigger HW5 CI with corrected paths6 --- .github/workflows/h5-tests.yml | 35 ++++++---------------------------- 1 file changed, 6 insertions(+), 29 deletions(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index e4bc2007..d30588ad 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -11,6 +11,9 @@ on: jobs: test-hw5: runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.12", "3.13"] services: postgres: @@ -31,10 +34,10 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Set up Python + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 with: - python-version: "3.12" + python-version: ${{ matrix.python-version }} - name: Install dependencies working-directory: hw5/hw @@ -42,38 +45,12 @@ jobs: python -m pip install --upgrade pip pip install -r requirements-test.txt - - name: Debug project structure - working-directory: hw5/hw - run: | - echo "=== Structure du projet ===" - pwd - ls -la - echo "=== Test présence database.py ===" - ls -la database.py - echo "=== Test import manuel ===" - python -c " - import sys - sys.path.append('.') - try: - from database import Base - print('✅ SUCCÈS: database.py peut être importé') - except Exception as e: - print(f'❌ ÉCHEC: {e}') - " - - name: Run tests with coverage working-directory: hw5/hw env: - DATABASE_URL: postgresql://postgres:password@postgres:5432/test_db + DATABASE_URL: postgresql://postgres:password@localhost:5432/test_db run: | - # Forcer l'ajout du chemin courant à Python export PYTHONPATH=$PYTHONPATH:$(pwd) - echo "PYTHONPATH: $PYTHONPATH" - - # Test final avant les tests - python -c "from database import Base; print('🎉 Import database RÉUSSI!'); from main import app; print('🎉 Import main RÉUSSI!')" - - # Lancement des tests pytest --cov=shop_api --cov-report=xml --cov-report=term-missing - name: Upload coverage to Codecov From 54722a157b5da39ffa333997c1c8dc20c764c0aa Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 04:06:59 +0300 Subject: [PATCH 40/48] test: Trigger HW5 CI with corrected paths6 --- .github/workflows/h5-tests.yml | 33 ++++++++++++++++++++++++++++----- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index d30588ad..2180296a 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -11,9 +11,6 @@ on: jobs: test-hw5: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12", "3.13"] services: postgres: @@ -34,10 +31,10 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python uses: actions/setup-python@v4 with: - python-version: ${{ matrix.python-version }} + python-version: "3.12" - name: Install dependencies working-directory: hw5/hw @@ -45,12 +42,38 @@ jobs: python -m pip install --upgrade pip pip install -r requirements-test.txt + - name: Debug project structure + working-directory: hw5/hw + run: | + echo "=== Structure du projet ===" + pwd + ls -la + echo "=== Test présence database.py ===" + ls -la database.py + echo "=== Test import manuel ===" + python -c " + import sys + sys.path.append('.') + try: + from database import Base + print('✅ SUCCÈS: database.py peut être importé') + except Exception as e: + print(f'❌ ÉCHEC: {e}') + " + - name: Run tests with coverage working-directory: hw5/hw env: DATABASE_URL: postgresql://postgres:password@localhost:5432/test_db run: | + # Forcer l'ajout du chemin courant à Python export PYTHONPATH=$PYTHONPATH:$(pwd) + echo "PYTHONPATH: $PYTHONPATH" + + # Test final avant les tests + python -c "from database import Base; print('🎉 Import database RÉUSSI!'); from main import app; print('🎉 Import main RÉUSSI!')" + + # Lancement des tests pytest --cov=shop_api --cov-report=xml --cov-report=term-missing - name: Upload coverage to Codecov From 0be51931aeeacd08b1c77944d1d442402bd9ce2f Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 04:08:39 +0300 Subject: [PATCH 41/48] test: Trigger HW5 CI with corrected paths7 --- .github/workflows/h5-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index 2180296a..e81cb90e 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -59,10 +59,10 @@ jobs: print('✅ SUCCÈS: database.py peut être importé') except Exception as e: print(f'❌ ÉCHEC: {e}') - " + " - name: Run tests with coverage - working-directory: hw5/hw + working-directory: hw5/hw env: DATABASE_URL: postgresql://postgres:password@localhost:5432/test_db run: | From 022f093096a1fc4bbc24006e2b8ed108db494c6a Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 04:27:42 +0300 Subject: [PATCH 42/48] test: Trigger HW5 CI with corrected paths8 --- .github/workflows/h5-tests.yml | 26 +++++++++++--------------- hw5/hw/tests/conftest.py | 30 +++++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index e81cb90e..77718639 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -36,6 +36,15 @@ jobs: with: python-version: "3.12" + - name: Wait for PostgreSQL to be ready + run: | + echo "Waiting for PostgreSQL to be ready..." + for i in {1..30}; do + nc -z localhost 5432 && echo "PostgreSQL is ready!" && break + echo "Waiting for PostgreSQL... attempt $i" + sleep 2 + done + - name: Install dependencies working-directory: hw5/hw run: | @@ -51,29 +60,16 @@ jobs: echo "=== Test présence database.py ===" ls -la database.py echo "=== Test import manuel ===" - python -c " - import sys - sys.path.append('.') - try: - from database import Base - print('✅ SUCCÈS: database.py peut être importé') - except Exception as e: - print(f'❌ ÉCHEC: {e}') - " + python -c "import sys; sys.path.append('.'); from database import Base; print('✅ SUCCÈS: database.py peut être importé')" - name: Run tests with coverage - working-directory: hw5/hw + working-directory: hw5/hw env: DATABASE_URL: postgresql://postgres:password@localhost:5432/test_db run: | - # Forcer l'ajout du chemin courant à Python export PYTHONPATH=$PYTHONPATH:$(pwd) echo "PYTHONPATH: $PYTHONPATH" - - # Test final avant les tests python -c "from database import Base; print('🎉 Import database RÉUSSI!'); from main import app; print('🎉 Import main RÉUSSI!')" - - # Lancement des tests pytest --cov=shop_api --cov-report=xml --cov-report=term-missing - name: Upload coverage to Codecov diff --git a/hw5/hw/tests/conftest.py b/hw5/hw/tests/conftest.py index d1228145..e3031b6a 100644 --- a/hw5/hw/tests/conftest.py +++ b/hw5/hw/tests/conftest.py @@ -1,17 +1,16 @@ -import pytest import sys import os +sys.path.append('/app') # Solution universelle - ajoute le chemin courant sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -from fastapi.testclient import TestClient - from database import Base, get_db from main import app +# Détection de l'environnement CI (GitHub Actions) # Configuration base de données IS_CI = os.getenv('GITHUB_ACTIONS') == 'true' TEST_DATABASE_URL = os.getenv("DATABASE_URL", @@ -19,14 +18,29 @@ else "sqlite:///./test.db" ) +# 🛠 Configuration de la base de données selon l'environnement +if IS_CI: + # ✅ En CI : PostgreSQL (nom d’hôte = localhost) + TEST_DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql://postgres:password@localhost:5432/test_db" + ) +else: + # ✅ En local : SQLite + TEST_DATABASE_URL = "sqlite:///./test.db" + +# Création du moteur SQLAlchemy engine = create_engine(TEST_DATABASE_URL) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + @pytest.fixture(scope="function") def db_session(): + """Crée une session de base de données temporaire pour les tests""" # 🎯 CORRECTION AVANCÉE : Utiliser les transactions Base.metadata.drop_all(bind=engine) Base.metadata.create_all(bind=engine) + session = TestingSessionLocal() connection = engine.connect() transaction = connection.begin() @@ -36,12 +50,19 @@ def db_session(): yield session finally: session.close() + # En local, on nettoie la base après chaque test + if not IS_CI: + Base.metadata.drop_all(bind=engine) + transaction.rollback() # Annule tous les changements connection.close() @pytest.fixture(scope="function") def client(db_session): + """Crée un client de test FastAPI en utilisant la session de test""" def override_get_db(): + yield db_session + try: yield db_session finally: @@ -49,5 +70,4 @@ def override_get_db(): app.dependency_overrides[get_db] = override_get_db client = TestClient(app) - yield client - app.dependency_overrides.clear() \ No newline at end of file + yield client \ No newline at end of file From 42193c13066844e51c5d6b7a53ac5d1416dd6f69 Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 18:45:01 +0300 Subject: [PATCH 43/48] test: Trigger HW5 CI with corrected paths7 --- hw5/hw/tests/conftest.py | 59 ++++++++++++---------------------------- 1 file changed, 18 insertions(+), 41 deletions(-) diff --git a/hw5/hw/tests/conftest.py b/hw5/hw/tests/conftest.py index e3031b6a..829c60e3 100644 --- a/hw5/hw/tests/conftest.py +++ b/hw5/hw/tests/conftest.py @@ -1,73 +1,50 @@ +import pytest import sys import os -sys.path.append('/app') -# Solution universelle - ajoute le chemin courant -sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +# Ajout du chemin du projet pour les imports +sys.path.append(os.path.dirname(os.path.abspath(__file__))) +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker +from fastapi.testclient import TestClient + from database import Base, get_db from main import app -# Détection de l'environnement CI (GitHub Actions) -# Configuration base de données +# Détermine si on est dans GitHub Actions (CI) IS_CI = os.getenv('GITHUB_ACTIONS') == 'true' -TEST_DATABASE_URL = os.getenv("DATABASE_URL", - "postgresql://postgres:password@localhost:5432/test_db" if IS_CI - else "sqlite:///./test.db" -) -# 🛠 Configuration de la base de données selon l'environnement -if IS_CI: - # ✅ En CI : PostgreSQL (nom d’hôte = localhost) - TEST_DATABASE_URL = os.getenv( - "DATABASE_URL", - "postgresql://postgres:password@localhost:5432/test_db" - ) -else: - # ✅ En local : SQLite - TEST_DATABASE_URL = "sqlite:///./test.db" +# Choix de la base de données selon l'environnement +TEST_DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql://postgres:password@localhost:5432/test_db" if IS_CI else "sqlite:///./test.db" +) -# Création du moteur SQLAlchemy +# Configuration SQLAlchemy engine = create_engine(TEST_DATABASE_URL) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - @pytest.fixture(scope="function") def db_session(): - """Crée une session de base de données temporaire pour les tests""" - # 🎯 CORRECTION AVANCÉE : Utiliser les transactions - Base.metadata.drop_all(bind=engine) + """Fixture de session de base de données""" Base.metadata.create_all(bind=engine) session = TestingSessionLocal() - - connection = engine.connect() - transaction = connection.begin() - session = TestingSessionLocal(bind=connection) - try: yield session finally: session.close() - # En local, on nettoie la base après chaque test - if not IS_CI: + if not IS_CI: # On nettoie seulement en local Base.metadata.drop_all(bind=engine) - transaction.rollback() # Annule tous les changements - connection.close() - @pytest.fixture(scope="function") def client(db_session): - """Crée un client de test FastAPI en utilisant la session de test""" + """Fixture client FastAPI""" def override_get_db(): yield db_session - try: - yield db_session - finally: - pass - app.dependency_overrides[get_db] = override_get_db client = TestClient(app) - yield client \ No newline at end of file + yield client + app.dependency_overrides.clear() From 94f9638b1572cdccae2dce026dfacfef0ab9942c Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 19:04:39 +0300 Subject: [PATCH 44/48] test: Trigger HW5 CI with corrected paths7 --- hw5/hw/tests/conftest.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/hw5/hw/tests/conftest.py b/hw5/hw/tests/conftest.py index 829c60e3..3816cae6 100644 --- a/hw5/hw/tests/conftest.py +++ b/hw5/hw/tests/conftest.py @@ -35,8 +35,7 @@ def db_session(): yield session finally: session.close() - if not IS_CI: # On nettoie seulement en local - Base.metadata.drop_all(bind=engine) + Base.metadata.drop_all(bind=engine) @pytest.fixture(scope="function") def client(db_session): From a049bad20528f9ffc0518d848d1f949659b31e16 Mon Sep 17 00:00:00 2001 From: sidoine Date: Tue, 21 Oct 2025 19:06:19 +0300 Subject: [PATCH 45/48] test: Trigger HW5 CI with corrected paths7 --- hw5/hw/tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hw5/hw/tests/conftest.py b/hw5/hw/tests/conftest.py index 3816cae6..6a71887d 100644 --- a/hw5/hw/tests/conftest.py +++ b/hw5/hw/tests/conftest.py @@ -2,7 +2,7 @@ import sys import os -# Ajout du chemin du projet pour les imports +# Ajout du chemin du projet pour les imports # sys.path.append(os.path.dirname(os.path.abspath(__file__))) sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) From 109afd7c35c10ca1b0bf5a0ab2c3b8f81d1cea4b Mon Sep 17 00:00:00 2001 From: sidoine Date: Wed, 22 Oct 2025 20:15:49 +0300 Subject: [PATCH 46/48] test: Trigger HW5 CI with corrected paths10 --- .github/workflows/h5-tests.yml | 44 ++++-------- hw5/hw/Dockerfile | 9 +-- hw5/hw/database.py | 24 +++---- hw5/hw/docker-compose.yml | 24 ++++++- hw5/hw/init_db_test.sql | 1 + hw5/hw/tests/conftest copy.py | 44 ------------ hw5/hw/tests/conftest.py | 68 ++++++++++++------- .../tests/test_queries/test_cart_queries.py | 55 ++++++--------- .../tests/test_routers/test_item_routers.py | 4 +- 9 files changed, 114 insertions(+), 159 deletions(-) create mode 100644 hw5/hw/init_db_test.sql delete mode 100644 hw5/hw/tests/conftest copy.py diff --git a/.github/workflows/h5-tests.yml b/.github/workflows/h5-tests.yml index 77718639..ca938657 100644 --- a/.github/workflows/h5-tests.yml +++ b/.github/workflows/h5-tests.yml @@ -1,12 +1,10 @@ name: "HW5 Tests" on: - pull_request: - branches: [ main ] - paths: [ 'hw5/hw/**' ] push: - branches: [ main ] - paths: [ 'hw5/hw/**' ] + branches: [main] + pull_request: + branches: [main] jobs: test-hw5: @@ -18,7 +16,7 @@ jobs: env: POSTGRES_USER: postgres POSTGRES_PASSWORD: password - POSTGRES_DB: test_db + POSTGRES_DB: test_shop_db options: >- --health-cmd pg_isready --health-interval 10s @@ -36,41 +34,27 @@ jobs: with: python-version: "3.12" - - name: Wait for PostgreSQL to be ready - run: | - echo "Waiting for PostgreSQL to be ready..." - for i in {1..30}; do - nc -z localhost 5432 && echo "PostgreSQL is ready!" && break - echo "Waiting for PostgreSQL... attempt $i" - sleep 2 - done - - name: Install dependencies working-directory: hw5/hw run: | python -m pip install --upgrade pip pip install -r requirements-test.txt - - name: Debug project structure - working-directory: hw5/hw + - name: Wait for PostgreSQL run: | - echo "=== Structure du projet ===" - pwd - ls -la - echo "=== Test présence database.py ===" - ls -la database.py - echo "=== Test import manuel ===" - python -c "import sys; sys.path.append('.'); from database import Base; print('✅ SUCCÈS: database.py peut être importé')" + for i in {1..30}; do + pg_isready -h localhost -p 5432 -U postgres && echo "Postgres ready" && break + echo "Waiting for PostgreSQL..." + sleep 2 + done - name: Run tests with coverage working-directory: hw5/hw env: - DATABASE_URL: postgresql://postgres:password@localhost:5432/test_db + DATABASE_URL: postgresql://postgres:password@localhost:5432/test_shop_db run: | export PYTHONPATH=$PYTHONPATH:$(pwd) - echo "PYTHONPATH: $PYTHONPATH" - python -c "from database import Base; print('🎉 Import database RÉUSSI!'); from main import app; print('🎉 Import main RÉUSSI!')" - pytest --cov=shop_api --cov-report=xml --cov-report=term-missing + pytest --cov=shop_api --cov-report=xml --cov-report=term-missing -v - name: Upload coverage to Codecov uses: codecov/codecov-action@v3 @@ -79,7 +63,7 @@ jobs: flags: unittests fail_ci_if_error: false - - name: Check 95% coverage threshold + - name: Check coverage threshold working-directory: hw5/hw run: | - python -m coverage report --fail-under=95 \ No newline at end of file + python -m coverage report --fail-under=95 diff --git a/hw5/hw/Dockerfile b/hw5/hw/Dockerfile index a11b8927..66e8a782 100644 --- a/hw5/hw/Dockerfile +++ b/hw5/hw/Dockerfile @@ -17,11 +17,12 @@ RUN python -m pip install --upgrade pip # Créer et définir le répertoire de travail WORKDIR /app -# Copier d'abord requirements.txt pour optimiser le cache Docker -COPY requirements.txt . +# Copier uniquement le fichier requirements-test.txt +COPY requirements-test.txt . -# Installer les dépendances Python -RUN pip install --no-cache-dir -r requirements.txt +# Installer les dépendances +RUN pip install --upgrade pip +RUN pip install -r requirements-test.txt # Copier le reste de l'application COPY . . diff --git a/hw5/hw/database.py b/hw5/hw/database.py index 3cd5602d..2f0a2ccc 100644 --- a/hw5/hw/database.py +++ b/hw5/hw/database.py @@ -1,23 +1,17 @@ +# database.py from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -#from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import declarative_base -# +from sqlalchemy.orm import sessionmaker, declarative_base import os -if os.getenv("TESTING"): - # Pour les tests - SQLite en mémoire (rapide et isolé) - DATABASE_URL = "sqlite:///:memory:" - # ou - DATABASE_URL = "sqlite:///./test.db" -else: - - DATABASE_URL = "postgresql://postgres:password@postgres:5432/shop_db" +def get_database_url(): + if os.getenv("TESTING"): + return "postgresql://postgres:password@postgres:5432/test_shop_db" + else: + return os.getenv("DATABASE_URL", "postgresql://postgres:password@postgres:5432/shop_db") +DATABASE_URL = get_database_url() engine = create_engine(DATABASE_URL) - SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - Base = declarative_base() def get_db(): @@ -25,4 +19,4 @@ def get_db(): try: yield db finally: - db.close() \ No newline at end of file + db.close() diff --git a/hw5/hw/docker-compose.yml b/hw5/hw/docker-compose.yml index 4d479fce..472ebca7 100644 --- a/hw5/hw/docker-compose.yml +++ b/hw5/hw/docker-compose.yml @@ -1,12 +1,13 @@ +version: "3.9" services: postgres: image: postgres:15 container_name: shop_postgres environment: - POSTGRES_DB: shop_db POSTGRES_USER: postgres POSTGRES_PASSWORD: password + POSTGRES_DB: shop_db ports: - "5432:5432" volumes: @@ -19,6 +20,7 @@ services: api: build: . + container_name: hw_api ports: - "8080:8080" environment: @@ -29,5 +31,23 @@ services: volumes: - .:/app + tests: + build: . + container_name: hw_tests + environment: + - TESTING=1 + - DATABASE_URL=postgresql://postgres:password@postgres:5432/test_shop_db + - PYTHONPATH=/app + depends_on: + postgres: + condition: service_healthy + volumes: + - .:/app + command: > + bash -c " + echo '🧪 Lancement des tests unitaires...'; + pytest -v --maxfail=1 --disable-warnings + " + volumes: - postgres_data: \ No newline at end of file + postgres_data: diff --git a/hw5/hw/init_db_test.sql b/hw5/hw/init_db_test.sql new file mode 100644 index 00000000..23d14d2e --- /dev/null +++ b/hw5/hw/init_db_test.sql @@ -0,0 +1 @@ +CREATE DATABASE test_shop_db; \ No newline at end of file diff --git a/hw5/hw/tests/conftest copy.py b/hw5/hw/tests/conftest copy.py deleted file mode 100644 index 5a292c94..00000000 --- a/hw5/hw/tests/conftest copy.py +++ /dev/null @@ -1,44 +0,0 @@ -import pytest -import sys -import os - -# AJOUTEZ CETTE LIGNE MANQUANTE -sys.path.append('/app') - -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker -from fastapi.testclient import TestClient - -from database import Base, get_db -from main import app - -# Utilise SQLite en local, PostgreSQL sur GitHub -IS_CI = os.getenv('GITHUB_ACTIONS') == 'true' -TEST_DATABASE_URL = os.getenv("DATABASE_URL", - "postgresql://postgres:password@postgres:5432/test_db" if IS_CI - else "sqlite:///./test.db" -) - -engine = create_engine(TEST_DATABASE_URL) -TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - -@pytest.fixture(scope="function") -def db_session(): - Base.metadata.create_all(bind=engine) - session = TestingSessionLocal() - try: - yield session - finally: - session.close() - if not IS_CI: # Ne supprime les tables qu'en local - Base.metadata.drop_all(bind=engine) - -@pytest.fixture(scope="function") -def client(db_session): - def override_get_db(): - yield db_session - - app.dependency_overrides[get_db] = override_get_db - client = TestClient(app) - yield client - app.dependency_overrides.clear() \ No newline at end of file diff --git a/hw5/hw/tests/conftest.py b/hw5/hw/tests/conftest.py index 6a71887d..a5d7d9df 100644 --- a/hw5/hw/tests/conftest.py +++ b/hw5/hw/tests/conftest.py @@ -1,49 +1,65 @@ -import pytest +# tests/conftest.py import sys import os +import pytest -# Ajout du chemin du projet pour les imports # -sys.path.append(os.path.dirname(os.path.abspath(__file__))) -sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +# Configuration silencieuse des imports +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +sys.path.append('/app') from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -from fastapi.testclient import TestClient +from database import Base # ton Base SQLAlchemy -from database import Base, get_db -from main import app - -# Détermine si on est dans GitHub Actions (CI) -IS_CI = os.getenv('GITHUB_ACTIONS') == 'true' +from fastapi.testclient import TestClient +from main import app # adapte selon ton projet +from shop_api.cart.routers import get_db as cart_get_db -# Choix de la base de données selon l'environnement +# ------------------------- +# Base de données de test +# ------------------------- TEST_DATABASE_URL = os.getenv( - "DATABASE_URL", - "postgresql://postgres:password@localhost:5432/test_db" if IS_CI else "sqlite:///./test.db" + "DATABASE_URL", "postgresql://postgres:password@postgres:5432/test_shop_db" ) -# Configuration SQLAlchemy -engine = create_engine(TEST_DATABASE_URL) +# Engine SQLAlchemy SILENCIEUX +engine = create_engine(TEST_DATABASE_URL, echo=False) # echo=False pour supprimer les logs SQL + +# Session factory pour les tests TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +# ------------------------- +# Fixture : session DB +# ------------------------- @pytest.fixture(scope="function") def db_session(): - """Fixture de session de base de données""" - Base.metadata.create_all(bind=engine) + """ + Fournit une session SQLAlchemy pour chaque test. + Crée toutes les tables avant le test et les supprime après. + """ + Base.metadata.create_all(bind=engine) # crée les tables session = TestingSessionLocal() try: yield session + session.commit() finally: session.close() - Base.metadata.drop_all(bind=engine) + Base.metadata.drop_all(bind=engine) # supprime les tables après le test +# ------------------------- +# Fixture : client FastAPI +# ------------------------- @pytest.fixture(scope="function") def client(db_session): - """Fixture client FastAPI""" - def override_get_db(): - yield db_session - - app.dependency_overrides[get_db] = override_get_db - client = TestClient(app) - yield client - app.dependency_overrides.clear() + def _get_test_db(): + try: + yield db_session + finally: + pass + + app.dependency_overrides[cart_get_db] = _get_test_db + + with TestClient(app) as c: + yield c + + app.dependency_overrides = {} \ No newline at end of file diff --git a/hw5/hw/tests/test_queries/test_cart_queries.py b/hw5/hw/tests/test_queries/test_cart_queries.py index 687a7f31..5bac555a 100644 --- a/hw5/hw/tests/test_queries/test_cart_queries.py +++ b/hw5/hw/tests/test_queries/test_cart_queries.py @@ -3,74 +3,59 @@ from shop_api.item.store.models import ItemInfo, ItemEntity, ItemDB class TestCartQueries: + def test_create_cart(self, db_session): """Test la création d'un panier""" entity = queries.create(db_session) - assert entity.id is not None assert entity.info.items == [] assert entity.info.price == 0.0 - + def test_get_one_cart(self, db_session): """Test la récupération d'un panier""" - # Crée un panier created = queries.create(db_session) - - # Récupère entity = queries.get_one(created.id, db_session) - assert entity is not None assert entity.id == created.id assert entity.info.price == 0.0 - + def test_add_item_to_cart(self, db_session): """Test l'ajout d'un item au panier""" - # Crée d'abord un item en base de données item_info = ItemInfo(name="Test Item", price=25.0, deleted=False) db_item = ItemDB(name=item_info.name, price=item_info.price, deleted=item_info.deleted) db_session.add(db_item) db_session.commit() db_session.refresh(db_item) - - # Crée un panier + cart = queries.create(db_session) - - # Crée l'entité item avec le bon ID item_entity = ItemEntity(id=db_item.id, info=item_info) - - # Ajoute au panier updated_cart = queries.add(cart.id, item_entity, db_session) - + assert updated_cart is not None assert len(updated_cart.info.items) == 1 assert updated_cart.info.items[0].name == "Test Item" assert updated_cart.info.items[0].quantity == 1 assert updated_cart.info.price == 25.0 - + def test_add_item_twice_increases_quantity(self, db_session): """Test que l'ajout du même item incrémente la quantité""" - # Crée d'abord un item en base item_info = ItemInfo(name="Test", price=10.0, deleted=False) db_item = ItemDB(name=item_info.name, price=item_info.price, deleted=item_info.deleted) db_session.add(db_item) db_session.commit() db_session.refresh(db_item) - - # Crée un panier + cart = queries.create(db_session) item_entity = ItemEntity(id=db_item.id, info=item_info) - - # Ajoute deux fois queries.add(cart.id, item_entity, db_session) updated_cart = queries.add(cart.id, item_entity, db_session) - + assert len(updated_cart.info.items) == 1 assert updated_cart.info.items[0].quantity == 2 assert updated_cart.info.price == 20.0 - + def test_get_many_carts_with_filters(self, db_session): """Test la récupération avec filtres""" - # Crée des items en base d'abord items = [] for i in range(3): db_item = ItemDB(name=f"Item{i}", price=10.0 * i, deleted=False) @@ -79,26 +64,24 @@ def test_get_many_carts_with_filters(self, db_session): db_session.commit() for item in items: db_session.refresh(item) - - # Crée plusieurs paniers avec différents items + carts = [] for i in range(3): cart = queries.create(db_session) - if i > 0: # Ajoute des items aux 2 derniers paniers - item_entity = ItemEntity(id=items[i].id, info=ItemInfo(name=items[i].name, price=items[i].price, deleted=False)) + if i > 0: + item_entity = ItemEntity( + id=items[i].id, + info=ItemInfo(name=items[i].name, price=items[i].price, deleted=False) + ) queries.add(cart.id, item_entity, db_session) carts.append(cart) - - # Filtre par prix min + filtered_carts = list(queries.get_many(db_session, min_price=10.0)) - assert len(filtered_carts) == 2 # Les paniers avec items (prix > 0) - + assert len(filtered_carts) == 2 + def test_delete_cart(self, db_session): """Test la suppression d'un panier""" cart = queries.create(db_session) - queries.delete(cart.id, db_session) - - # Vérifie qu'il n'existe plus result = queries.get_one(cart.id, db_session) - assert result is None \ No newline at end of file + assert result is None diff --git a/hw5/hw/tests/test_routers/test_item_routers.py b/hw5/hw/tests/test_routers/test_item_routers.py index f75a0f0a..879851e5 100644 --- a/hw5/hw/tests/test_routers/test_item_routers.py +++ b/hw5/hw/tests/test_routers/test_item_routers.py @@ -25,14 +25,14 @@ def test_create_item_invalid_data(self, client): "/item/", json={"name": "Test", "price": -10.0} ) - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT # Données manquantes response = client.post( "/item/", json={"name": "Test"} # price manquant ) - assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT def test_get_item_by_id_success(self, client, db_session): """Test la récupération d'un item existant""" From 4593977f45a472c4c06a3f326c276aab9b653901 Mon Sep 17 00:00:00 2001 From: sidoine Date: Thu, 23 Oct 2025 19:51:55 +0300 Subject: [PATCH 47/48] test: Trigger HW50 --- hw5/hw/tests/conftest.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/hw5/hw/tests/conftest.py b/hw5/hw/tests/conftest.py index a5d7d9df..d8523cf2 100644 --- a/hw5/hw/tests/conftest.py +++ b/hw5/hw/tests/conftest.py @@ -3,52 +3,44 @@ import os import pytest -# Configuration silencieuse des imports + sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) sys.path.append('/app') from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker -from database import Base # ton Base SQLAlchemy +from database import Base from fastapi.testclient import TestClient from main import app # adapte selon ton projet from shop_api.cart.routers import get_db as cart_get_db -# ------------------------- -# Base de données de test -# ------------------------- + TEST_DATABASE_URL = os.getenv( "DATABASE_URL", "postgresql://postgres:password@postgres:5432/test_shop_db" ) -# Engine SQLAlchemy SILENCIEUX -engine = create_engine(TEST_DATABASE_URL, echo=False) # echo=False pour supprimer les logs SQL -# Session factory pour les tests +engine = create_engine(TEST_DATABASE_URL, echo=False) + + TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) -# ------------------------- -# Fixture : session DB -# ------------------------- @pytest.fixture(scope="function") def db_session(): """ Fournit une session SQLAlchemy pour chaque test. Crée toutes les tables avant le test et les supprime après. """ - Base.metadata.create_all(bind=engine) # crée les tables + Base.metadata.create_all(bind=engine) session = TestingSessionLocal() try: yield session session.commit() finally: session.close() - Base.metadata.drop_all(bind=engine) # supprime les tables après le test + Base.metadata.drop_all(bind=engine) -# ------------------------- -# Fixture : client FastAPI -# ------------------------- @pytest.fixture(scope="function") def client(db_session): def _get_test_db(): From 81835ba6a11f8e598483360c2fe0453ae27740d6 Mon Sep 17 00:00:00 2001 From: sidoine Date: Thu, 23 Oct 2025 20:31:30 +0300 Subject: [PATCH 48/48] test: Trigger HW5 --- hw5/hw/tests/test_edge_cases.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/hw5/hw/tests/test_edge_cases.py b/hw5/hw/tests/test_edge_cases.py index 595e9849..303eab57 100644 --- a/hw5/hw/tests/test_edge_cases.py +++ b/hw5/hw/tests/test_edge_cases.py @@ -15,9 +15,8 @@ def test_add_item_to_nonexistent_cart(self, db_session): item_entity = ItemEntity(id=db_item.id, info=item_info) - # Essayer d'ajouter à un panier inexistant result = cart_queries.add(999999, item_entity, db_session) - assert result is None # Devrait retourner None + assert result is None def test_get_many_carts_edge_cases(self, db_session): """Test get_many avec des filtres extrêmes"""