From eae2a60336fcc30f3fe0a0e9fe9cb8a74a3bf5c1 Mon Sep 17 00:00:00 2001 From: Greg Date: Sat, 27 Sep 2025 14:54:24 +0300 Subject: [PATCH 01/10] intermediate --- hw1/app.py | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 2 deletions(-) diff --git a/hw1/app.py b/hw1/app.py index 6107b870..7f7166aa 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,5 +1,7 @@ from typing import Any, Awaitable, Callable - +import json +import math +from urllib.parse import parse_qs async def application( scope: dict[str, Any], @@ -12,8 +14,66 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if scope['type'] != 'http': + return + method = scope['method'] + path = scope['path'] + query_string = scope.get('query_string', b'').decode('utf-8') + query_params = parse_qs(query_string) + + async def send_response(status_code, body): + await send({ + 'type':'http.response.start', + 'status': status_code, + 'headers': [ + [b'content-type', b'application/json'], + ], + }) + await send({ + 'type': 'http.response.body', + 'body': json.dumps(body).encode('utf-8'), + }) + async def receive_body(): + body = b'' + while True: + message = await receive() + if message['type'] == 'http.request': + body += message.get('body', 'b') + if not message.get('more_body', False): + break + if body: + try: + return json.loads(body.decode('utf-8')) + except json.JSONDecodeError: + return None + return None + if method != 'GET': + await send_response(404, {}) + return + + try: + if path == '/factorial': + if 'n' not in query_params or not query_params['n'][0]: + await send_response(422, {}) + return + + try: + n = int(query_params['n'][0]) + if n < 0: + await send_response(400, {}) + return + + result = math.factorial(n) + await send_response(200, {'result': result}) + return + except ValueError: + await send_response(422, {}) + return + + except Exception: + await send_response(500, {}) + return if __name__ == "__main__": import uvicorn uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) From d84a6cd39a534b2a0812f85748fde1c8d63f2faf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=93=D1=80=D0=B8=D0=B3=D0=BE=D1=80=D0=B8=D0=B9=20=D0=9E?= =?UTF-8?q?=D1=80=D0=BB=D0=BE=D0=B2?= Date: Sat, 27 Sep 2025 21:26:29 +0300 Subject: [PATCH 02/10] complete --- hw1/app.py | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/hw1/app.py b/hw1/app.py index 7f7166aa..ede3fe23 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -38,7 +38,7 @@ async def receive_body(): while True: message = await receive() if message['type'] == 'http.request': - body += message.get('body', 'b') + body += message.get('body', b'') if not message.get('more_body', False): break if body: @@ -52,6 +52,7 @@ async def receive_body(): return try: + if path == '/factorial': if 'n' not in query_params or not query_params['n'][0]: await send_response(422, {}) @@ -69,7 +70,62 @@ async def receive_body(): except ValueError: await send_response(422, {}) return - + + elif path.startswith('/fibonacci/'): + try: + n_str = path[11:] + if not n_str: + await send_response(422, {}) + return + n = int(n_str) + + if n < 0: + await send_response(400, {}) + return + if n == 0: + await send_response(200, {'result': 0}) + return + elif n == 1: + await send_response(200, {'result': 1}) + return + else: + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + await send_response(200, {'result': b}) + return + + except Exception: + await send_response(422, {}) + return + + elif path == '/mean': + body = await receive_body() + + if body is None: + await send_response(422, {}) + return + + if not isinstance(body, list): + await send_response(400, {}) + return + + if len(body) == 0: + await send_response(400, {}) + return + try: + numbers = [float(x) for x in body] + mean_value = sum(numbers) / len(numbers) + await send_response(200, {'result': mean_value}) + return + except (ValueError, TypeError): + await send_response(400, {}) + return + + else: + await send_response(404, {}) + return + except Exception: await send_response(500, {}) From f3b516acc27c9724275cc7a8e85f127368b50698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=93=D1=80=D0=B8=D0=B3=D0=BE=D1=80=D0=B8=D0=B9=20=D0=9E?= =?UTF-8?q?=D1=80=D0=BB=D0=BE=D0=B2?= Date: Sat, 27 Sep 2025 21:35:12 +0300 Subject: [PATCH 03/10] fixed lifespan issue --- hw1/app.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/hw1/app.py b/hw1/app.py index ede3fe23..55bb4104 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -14,8 +14,20 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ + + if scope['type'] == 'lifespan': + while True: + message = await receive() + if message['type'] == 'lifespan.startup': + await send({'type': 'lifespan.startup.complete'}) + elif message['type'] == 'lifespan.shutdown': + await send({'type': 'lifespan.shutdown.complete'}) + break + return + if scope['type'] != 'http': return + method = scope['method'] path = scope['path'] query_string = scope.get('query_string', b'').decode('utf-8') @@ -33,6 +45,7 @@ async def send_response(status_code, body): 'type': 'http.response.body', 'body': json.dumps(body).encode('utf-8'), }) + async def receive_body(): body = b'' while True: @@ -47,12 +60,12 @@ async def receive_body(): except json.JSONDecodeError: return None return None + if method != 'GET': await send_response(404, {}) return try: - if path == '/factorial': if 'n' not in query_params or not query_params['n'][0]: await send_response(422, {}) @@ -126,10 +139,10 @@ async def receive_body(): await send_response(404, {}) return - except Exception: await send_response(500, {}) return + 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 8c1db232d5dc24f931ceb3098fa8fc056305c834 Mon Sep 17 00:00:00 2001 From: Greg Date: Sat, 4 Oct 2025 18:54:06 +0300 Subject: [PATCH 04/10] hw2 solution --- hw2/hw/shop_api/main.py | 267 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 266 insertions(+), 1 deletion(-) diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..fe2497d3 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,268 @@ -from fastapi import FastAPI +from fastapi import FastAPI, HTTPException, Response, Query, WebSocket, WebSocketDisconnect +from pydantic import BaseModel, Field, ConfigDict +from typing import Optional +from http import HTTPStatus +import random +import string app = FastAPI(title="Shop API") + +class ItemCreate(BaseModel): + name: str + price: float = Field(gt=0) + +class ItemUpdate(BaseModel): + name: str + price: float = Field(gt=0) + +class ItemPatch(BaseModel): + model_config = ConfigDict(extra='forbid') + + name: Optional[str] = None + price: Optional[float] = Field(None, gt=0) + +class Item(BaseModel): + id: int + name: str + price: float + deleted: bool = False + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + +class Cart(BaseModel): + id: int + items: list[CartItem] + price: float + +class CartIdResponse(BaseModel): + id: int + +items_db: dict[int, Item] = {} +carts_db: dict[int, dict[int, int]] = {} +item_counter = 0 +cart_counter = 0 + +chat_rooms: dict[str, list[tuple[WebSocket, str]]] = {} + +def generate_username(): + adjectives = ["Happy", "Clever", "Brave", "Swift", "Strong", "Wise", "Cool", "Epic"] + nouns = ["Panda", "Tiger", "Eagle", "Dragon", "Phoenix", "Wolf", "Bear", "Fox"] + number = random.randint(100, 999) + return f"{random.choice(adjectives)}{random.choice(nouns)}{number}" + + + +@app.post("/item", status_code=HTTPStatus.CREATED, response_model=Item) +def create_item(item: ItemCreate): + global item_counter + item_counter += 1 + + new_item = Item( + id=item_counter, + name=item.name, + price=item.price, + deleted=False + ) + items_db[item_counter] = new_item + return new_item + + +@app.get("/item/{item_id}", response_model=Item) +def get_item(item_id: int): + if item_id not in items_db: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + + item = items_db[item_id] + if item.deleted: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + + return item + + +@app.get("/item", response_model=list[Item]) +def get_items( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + show_deleted: bool = False +): + filtered_items = [] + + for item in items_db.values(): + if not show_deleted and item.deleted: + continue + + if min_price is not None and item.price < min_price: + continue + if max_price is not None and item.price > max_price: + continue + + filtered_items.append(item) + + return filtered_items[offset:offset + limit] + + +@app.put("/item/{item_id}", response_model=Item) +def update_item(item_id: int, item: ItemUpdate): + if item_id not in items_db: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + + existing_item = items_db[item_id] + existing_item.name = item.name + existing_item.price = item.price + + return existing_item + + +@app.patch("/item/{item_id}", response_model=Item) +def patch_item(item_id: int, item: ItemPatch): + if item_id not in items_db: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + + existing_item = items_db[item_id] + + if existing_item.deleted: + return Response(status_code=HTTPStatus.NOT_MODIFIED) + + if item.name is not None: + existing_item.name = item.name + if item.price is not None: + existing_item.price = item.price + + return existing_item + + +@app.delete("/item/{item_id}") +def delete_item(item_id: int): + if item_id not in items_db: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + + items_db[item_id].deleted = True + return Response(status_code=HTTPStatus.OK) + + +@app.post("/cart", status_code=HTTPStatus.CREATED, response_model=CartIdResponse) +def create_cart(response: Response): + global cart_counter + cart_counter += 1 + + carts_db[cart_counter] = {} + response.headers["location"] = f"/cart/{cart_counter}" + + return CartIdResponse(id=cart_counter) + + +@app.get("/cart/{cart_id}", response_model=Cart) +def get_cart(cart_id: int): + if cart_id not in carts_db: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Cart not found") + + cart_items_data = carts_db[cart_id] + cart_items = [] + total_price = 0.0 + + for item_id, quantity in cart_items_data.items(): + if item_id in items_db: + item = items_db[item_id] + cart_items.append(CartItem( + id=item.id, + name=item.name, + quantity=quantity, + available=not item.deleted + )) + if not item.deleted: + total_price += item.price * quantity + + return Cart( + id=cart_id, + items=cart_items, + price=total_price + ) + + +@app.get("/cart", response_model=list[Cart]) +def get_carts( + offset: int = Query(0, ge=0), + limit: int = Query(10, gt=0), + min_price: Optional[float] = Query(None, ge=0), + max_price: Optional[float] = Query(None, ge=0), + min_quantity: Optional[int] = Query(None, ge=0), + max_quantity: Optional[int] = Query(None, ge=0) +): + filtered_carts = [] + + for cart_id in carts_db.keys(): + cart = get_cart(cart_id) + + + total_quantity = sum(item.quantity for item in cart.items) + + if min_price is not None and cart.price < min_price: + continue + if max_price is not None and cart.price > max_price: + continue + + if min_quantity is not None and total_quantity < min_quantity: + continue + if max_quantity is not None and total_quantity > max_quantity: + continue + + filtered_carts.append(cart) + + return filtered_carts[offset:offset + limit] + + +@app.post("/cart/{cart_id}/add/{item_id}") +def add_item_to_cart(cart_id: int, item_id: int): + if cart_id not in carts_db: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Cart not found") + + if item_id not in items_db: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + + if item_id in carts_db[cart_id]: + carts_db[cart_id][item_id] += 1 + else: + carts_db[cart_id][item_id] = 1 + + return Response(status_code=HTTPStatus.OK) + +@app.websocket("/chat/{chat_name}") +async def chat_endpoint(websocket: WebSocket, chat_name: str): + await websocket.accept() + + + username = generate_username() + + + if chat_name not in chat_rooms: + chat_rooms[chat_name] = [] + + + chat_rooms[chat_name].append((websocket, username)) + + try: + while True: + message = await websocket.receive_text() + + formatted_message = f"{username} :: {message}" + + for ws, user in chat_rooms[chat_name]: + try: + await ws.send_text(formatted_message) + except: + pass + + except WebSocketDisconnect: + chat_rooms[chat_name] = [ + (ws, user) for ws, user in chat_rooms[chat_name] + if ws != websocket + ] + + if not chat_rooms[chat_name]: + del chat_rooms[chat_name] \ No newline at end of file From c7dbfe1c95a8a5325bd311efac72c1a646ba1f04 Mon Sep 17 00:00:00 2001 From: Greg Date: Sat, 4 Oct 2025 19:08:44 +0300 Subject: [PATCH 05/10] testing chat websocket --- hw2/test_chat.html | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 hw2/test_chat.html diff --git a/hw2/test_chat.html b/hw2/test_chat.html new file mode 100644 index 00000000..8aeab5db --- /dev/null +++ b/hw2/test_chat.html @@ -0,0 +1,35 @@ + + + + WebSocket Chat + + +

Chat Room:

+
+ + + + + + \ No newline at end of file From e6dc809877e2a186f66fc9780a32797368d731f3 Mon Sep 17 00:00:00 2001 From: Greg Date: Sat, 11 Oct 2025 18:49:58 +0300 Subject: [PATCH 06/10] hw3 added monitoring, dockerfile&docker-compose --- hw2/hw/.dockerignore | 26 + hw2/hw/Dockerfile | 21 + hw2/hw/docker-compose.yml | 65 +++ .../dashboards/shop-api-dashboard.json | 549 ++++++++++++++++++ .../provisioning/dashboards/dashboard.yml | 12 + .../provisioning/datasources/prometheus.yml | 13 + hw2/hw/prometheus/prometheus.yml | 17 + hw2/hw/requirements.txt | 10 +- hw2/hw/shop_api/main.py | 8 +- 9 files changed, 719 insertions(+), 2 deletions(-) create mode 100644 hw2/hw/.dockerignore create mode 100644 hw2/hw/Dockerfile create mode 100644 hw2/hw/docker-compose.yml create mode 100644 hw2/hw/grafana/dashboards/shop-api-dashboard.json create mode 100644 hw2/hw/grafana/provisioning/dashboards/dashboard.yml create mode 100644 hw2/hw/grafana/provisioning/datasources/prometheus.yml create mode 100644 hw2/hw/prometheus/prometheus.yml diff --git a/hw2/hw/.dockerignore b/hw2/hw/.dockerignore new file mode 100644 index 00000000..17080605 --- /dev/null +++ b/hw2/hw/.dockerignore @@ -0,0 +1,26 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info +dist +build +.git +.gitignore +.env +.venv +env/ +venv/ +ENV/ +.pytest_cache +.coverage +htmlcov/ +*.log +.DS_Store +Thumbs.db +README.md +docker-compose.yml +Dockerfile \ No newline at end of file diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..1904bc55 --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,21 @@ + +FROM python:3.13-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . + +RUN pip install --no-cache-dir -r requirements.txt + +RUN pip install --no-cache-dir prometheus-client prometheus-fastapi-instrumentator + +COPY shop_api/ ./shop_api/ + +EXPOSE 8000 + + +ENV PYTHONUNBUFFERED=1 + +CMD ["uvicorn", "shop_api.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..9d30d877 --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,65 @@ +version: '3.8' + +services: + shop-api: + build: + context: . + dockerfile: Dockerfile + container_name: shop-api + ports: + - "8000:8000" + networks: + - monitoring + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + + prometheus: + image: prom/prometheus:latest + container_name: prometheus + ports: + - "9090:9090" + volumes: + - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml + - prometheus-data:/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' + networks: + - monitoring + restart: unless-stopped + depends_on: + - shop-api + + grafana: + image: grafana/grafana:latest + container_name: grafana + ports: + - "3000:3000" + volumes: + - grafana-data:/var/lib/grafana + - ./grafana/provisioning:/etc/grafana/provisioning + - ./grafana/dashboards:/var/lib/grafana/dashboards + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_USERS_ALLOW_SIGN_UP=false + networks: + - monitoring + restart: unless-stopped + depends_on: + - prometheus + +networks: + monitoring: + driver: bridge + +volumes: + prometheus-data: + grafana-data: \ No newline at end of file diff --git a/hw2/hw/grafana/dashboards/shop-api-dashboard.json b/hw2/hw/grafana/dashboards/shop-api-dashboard.json new file mode 100644 index 00000000..07257adc --- /dev/null +++ b/hw2/hw/grafana/dashboards/shop-api-dashboard.json @@ -0,0 +1,549 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Requests/sec", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "reqps" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": ["mean", "max"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "expr": "rate(http_requests_total{job=\"shop-api\"}[1m])", + "refId": "A", + "legendFormat": "{{method}} {{handler}}" + } + ], + "title": "Request Rate (requests/sec)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 0.1 + }, + { + "color": "orange", + "value": 0.5 + }, + { + "color": "red", + "value": 1 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "showThresholdLabels": false, + "showThresholdMarkers": true, + "text": {} + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "expr": "histogram_quantile(0.95, rate(http_request_duration_seconds_bucket{job=\"shop-api\"}[5m]))", + "refId": "A" + } + ], + "title": "Response Time (95th percentile)", + "type": "gauge" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + } + }, + "mappings": [], + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "displayMode": "table", + "placement": "right", + "showLegend": true, + "values": ["value", "percent"] + }, + "pieType": "donut", + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "targets": [ + { + "expr": "sum by (status) (http_requests_total{job=\"shop-api\"})", + "refId": "A", + "legendFormat": "{{status}}" + } + ], + "title": "HTTP Status Codes Distribution", + "type": "piechart" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "Requests", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "bars", + "fillOpacity": 80, + "gradientMode": "none", + "hideFrom": { + "tooltip": false, + "viz": false, + "legend": false + }, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "normal" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "options": { + "legend": { + "calcs": ["sum"], + "displayMode": "table", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "expr": "sum by (handler) (increase(http_requests_total{job=\"shop-api\"}[5m]))", + "refId": "A", + "legendFormat": "{{handler}}" + } + ], + "title": "Requests by Endpoint (last 5 min)", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 0, + "y": 16 + }, + "id": 5, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "expr": "sum(increase(http_requests_total{job=\"shop-api\"}[1h]))", + "refId": "A" + } + ], + "title": "Total Requests (last hour)", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "yellow", + "value": 0.05 + }, + { + "color": "red", + "value": 0.1 + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 6, + "y": 16 + }, + "id": 6, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "mean" + ], + "fields": "" + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "expr": "rate(http_request_duration_seconds_sum{job=\"shop-api\"}[5m]) / rate(http_request_duration_seconds_count{job=\"shop-api\"}[5m])", + "refId": "A" + } + ], + "title": "Average Response Time", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "red", + "value": null + }, + { + "color": "green", + "value": 1 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 12, + "y": 16 + }, + "id": 7, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "lastNotNull" + ], + "fields": "" + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "expr": "up{job=\"shop-api\"}", + "refId": "A" + } + ], + "title": "Service Status (1=UP, 0=DOWN)", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 4, + "w": 6, + "x": 18, + "y": 16 + }, + "id": 8, + "options": { + "colorMode": "value", + "graphMode": "area", + "justifyMode": "auto", + "orientation": "auto", + "reduceOptions": { + "values": false, + "calcs": [ + "mean" + ], + "fields": "" + }, + "text": {}, + "textMode": "auto" + }, + "pluginVersion": "10.0.0", + "targets": [ + { + "expr": "rate(http_request_size_bytes_sum{job=\"shop-api\"}[5m]) / rate(http_request_size_bytes_count{job=\"shop-api\"}[5m])", + "refId": "A" + } + ], + "title": "Average Request Size", + "type": "stat" + } + ], + "refresh": "5s", + "schemaVersion": 38, + "style": "dark", + "tags": ["shop-api", "fastapi"], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "Shop API Dashboard", + "uid": "shop-api-dashboard", + "version": 0, + "weekStart": "" +} \ No newline at end of file diff --git a/hw2/hw/grafana/provisioning/dashboards/dashboard.yml b/hw2/hw/grafana/provisioning/dashboards/dashboard.yml new file mode 100644 index 00000000..a8c80567 --- /dev/null +++ b/hw2/hw/grafana/provisioning/dashboards/dashboard.yml @@ -0,0 +1,12 @@ +apiVersion: 1 + +providers: + - name: 'Shop API Dashboards' + orgId: 1 + folder: '' + type: file + disableDeletion: false + updateIntervalSeconds: 10 + allowUiUpdates: true + options: + path: /var/lib/grafana/dashboards \ No newline at end of file diff --git a/hw2/hw/grafana/provisioning/datasources/prometheus.yml b/hw2/hw/grafana/provisioning/datasources/prometheus.yml new file mode 100644 index 00000000..fb0e312f --- /dev/null +++ b/hw2/hw/grafana/provisioning/datasources/prometheus.yml @@ -0,0 +1,13 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true + editable: true + uid: prometheus + jsonData: + timeInterval: "5s" + httpMethod: POST \ No newline at end of file diff --git a/hw2/hw/prometheus/prometheus.yml b/hw2/hw/prometheus/prometheus.yml new file mode 100644 index 00000000..7aba99cf --- /dev/null +++ b/hw2/hw/prometheus/prometheus.yml @@ -0,0 +1,17 @@ + +global: + scrape_interval: 15s + evaluation_interval: 15s + external_labels: + monitor: 'shop-api-monitor' + +scrape_configs: + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + - job_name: 'shop-api' + scrape_interval: 5s + static_configs: + - targets: ['shop-api:8000'] + metrics_path: '/metrics' \ No newline at end of file diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..0328edee 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,9 +1,17 @@ + # Основные зависимости для ASGI приложения fastapi>=0.117.1 uvicorn>=0.24.0 + +websockets>=12.0 + +# Prometheus metrics +prometheus-client>=0.19.0 +prometheus-fastapi-instrumentator>=6.1.0 + # Зависимости для тестирования pytest>=7.4.0 pytest-asyncio>=0.21.0 httpx>=0.27.2 -Faker>=37.8.0 +Faker>=37.8.0 \ No newline at end of file diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index fe2497d3..4544784f 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -3,10 +3,13 @@ from typing import Optional from http import HTTPStatus import random -import string +from prometheus_fastapi_instrumentator import Instrumentator app = FastAPI(title="Shop API") +instrumentator = Instrumentator() +instrumentator.instrument(app).expose(app) + class ItemCreate(BaseModel): name: str price: float = Field(gt=0) @@ -54,6 +57,9 @@ def generate_username(): number = random.randint(100, 999) return f"{random.choice(adjectives)}{random.choice(nouns)}{number}" +@app.get("/health") +def health_check(): + return {"status": "healthy"} @app.post("/item", status_code=HTTPStatus.CREATED, response_model=Item) From c49f7472275e7c1fed29b32f98e6442318938eee Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 26 Oct 2025 20:53:48 +0300 Subject: [PATCH 07/10] hw4 & hw5 --- hw2/hw/.coveragerc | 19 + hw2/hw/.github/workflows/tests.yml | 123 ++++++ hw2/hw/Dockerfile | 9 +- hw2/hw/Dockerfile.test | 18 + hw2/hw/conftest.py | 76 ++++ hw2/hw/create_test_db.py | 17 + hw2/hw/docker-compose.test.yml | 32 ++ hw2/hw/docker-compose.yml | 30 +- hw2/hw/pytest.ini | 14 + hw2/hw/requirements.txt | 7 +- hw2/hw/setup.py | 8 + hw2/hw/shop_api/main.py | 291 ++++++++----- hw2/hw/test_homework2_all.py | 605 +++++++++++++++++++++++++++ hw2/hw/transaction_isolation_demo.py | 339 +++++++++++++++ 14 files changed, 1467 insertions(+), 121 deletions(-) create mode 100644 hw2/hw/.coveragerc create mode 100644 hw2/hw/.github/workflows/tests.yml create mode 100644 hw2/hw/Dockerfile.test create mode 100644 hw2/hw/conftest.py create mode 100644 hw2/hw/create_test_db.py create mode 100644 hw2/hw/docker-compose.test.yml create mode 100644 hw2/hw/pytest.ini create mode 100644 hw2/hw/setup.py create mode 100644 hw2/hw/test_homework2_all.py create mode 100644 hw2/hw/transaction_isolation_demo.py diff --git a/hw2/hw/.coveragerc b/hw2/hw/.coveragerc new file mode 100644 index 00000000..52a40962 --- /dev/null +++ b/hw2/hw/.coveragerc @@ -0,0 +1,19 @@ +[run] +source = shop_api +concurrency = thread,greenlet +omit = + */tests/* + */test_*.py + */__pycache__/* + */venv/* + */env/* + +[report] +exclude_lines = + pragma: no cover + def __repr__ + raise AssertionError + raise NotImplementedError + if __name__ == .__main__.: + if TYPE_CHECKING: + @abstractmethod diff --git a/hw2/hw/.github/workflows/tests.yml b/hw2/hw/.github/workflows/tests.yml new file mode 100644 index 00000000..cb1661cc --- /dev/null +++ b/hw2/hw/.github/workflows/tests.yml @@ -0,0 +1,123 @@ +name: Tests + +on: + push: + branches: [ main, dev ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: shop_db_test + POSTGRES_USER: shop_user + POSTGRES_PASSWORD: shop_password + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' + cache: 'pip' + + - name: Install dependencies + working-directory: ./hw2/hw + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e . + + - name: Wait for PostgreSQL + run: | + until pg_isready -h localhost -p 5432 -U shop_user; do + echo "Waiting for postgres..." + sleep 2 + done + + - name: Run tests with coverage + working-directory: ./hw2/hw + env: + DATABASE_URL: postgresql+asyncpg://shop_user:shop_password@localhost:5432/shop_db_test + run: | + pytest test_homework2_all.py -v --cov=shop_api --cov-report=xml --cov-report=term + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./hw2/hw/coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + + - name: Check coverage threshold + working-directory: ./hw2/hw + run: | + coverage report --fail-under=95 + + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' + + - name: Install linting tools + run: | + python -m pip install --upgrade pip + pip install flake8 black isort + + - name: Run flake8 + working-directory: ./hw2/hw + run: | + flake8 shop_api --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 shop_api --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Check formatting with black + working-directory: ./hw2/hw + run: | + black --check shop_api || true + + - name: Check imports with isort + working-directory: ./hw2/hw + run: | + isort --check-only shop_api || true + + docker-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + working-directory: ./hw2/hw + run: | + docker build -t shop-api:test . + + - name: Test Docker Compose + working-directory: ./hw2/hw + run: | + docker-compose config \ No newline at end of file diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile index 1904bc55..825e9282 100644 --- a/hw2/hw/Dockerfile +++ b/hw2/hw/Dockerfile @@ -1,5 +1,5 @@ -FROM python:3.13-slim +FROM python:3.11-slim WORKDIR /app @@ -12,9 +12,14 @@ RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir prometheus-client prometheus-fastapi-instrumentator COPY shop_api/ ./shop_api/ +COPY transaction_isolation_demo.py . +COPY test_homework2_all.py . +COPY setup.py . -EXPOSE 8000 +# Устанавливаем пакет shop_api +RUN pip install -e . +EXPOSE 8000 ENV PYTHONUNBUFFERED=1 diff --git a/hw2/hw/Dockerfile.test b/hw2/hw/Dockerfile.test new file mode 100644 index 00000000..7db2eb57 --- /dev/null +++ b/hw2/hw/Dockerfile.test @@ -0,0 +1,18 @@ +FROM python:3.9-slim + +WORKDIR /app + + +RUN apt-get update && apt-get install -y \ + postgresql-client \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + + +RUN pip install -e . + +CMD ["pytest", "test_homework2_all.py", "-v"] diff --git a/hw2/hw/conftest.py b/hw2/hw/conftest.py new file mode 100644 index 00000000..96fc64b5 --- /dev/null +++ b/hw2/hw/conftest.py @@ -0,0 +1,76 @@ +import os +import pytest +import pytest_asyncio +from httpx import AsyncClient, ASGITransport +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.pool import NullPool +from contextlib import asynccontextmanager + +# Используем DATABASE_URL из окружения, или дефолтное значение для локальных тестов +os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://shop_user:shop_password@localhost:5432/shop_db_test") + +from shop_api.main import app, Base, get_session + +test_engine = create_async_engine( + os.environ["DATABASE_URL"], + echo=False, + poolclass=NullPool # No connection pooling for tests +) +test_session_maker = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False) + + +@pytest_asyncio.fixture(scope="session", autouse=True) +async def setup_database(): + @asynccontextmanager + async def empty_lifespan(app): + yield + + app.router.lifespan_context = empty_lifespan + + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + yield + + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + await test_engine.dispose() + + +@pytest_asyncio.fixture +async def db_session(): + async with test_session_maker() as session: + yield session + + +@pytest_asyncio.fixture +async def client(db_session: AsyncSession): + async def override_get_session(): + yield db_session + + app.dependency_overrides[get_session] = override_get_session + + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + timeout=30.0 + ) as ac: + yield ac + + app.dependency_overrides.clear() + + +@pytest.fixture(scope="session") +def event_loop_policy(): + import asyncio + return asyncio.DefaultEventLoopPolicy() + + +@pytest.fixture(scope="session") +def event_loop(event_loop_policy): + import asyncio + loop = event_loop_policy.new_event_loop() + yield loop + loop.close() diff --git a/hw2/hw/create_test_db.py b/hw2/hw/create_test_db.py new file mode 100644 index 00000000..f229c3d9 --- /dev/null +++ b/hw2/hw/create_test_db.py @@ -0,0 +1,17 @@ +import asyncio +import os + +os.environ["DATABASE_URL"] = "postgresql+asyncpg://shop_user:shop_password@localhost:5432/shop_db_test" + +from shop_api.main import engine, Base + +async def create_tables(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + await engine.dispose() + print("Test database tables created successfully!") + +if __name__ == "__main__": + asyncio.run(create_tables()) diff --git a/hw2/hw/docker-compose.test.yml b/hw2/hw/docker-compose.test.yml new file mode 100644 index 00000000..67e7b5ae --- /dev/null +++ b/hw2/hw/docker-compose.test.yml @@ -0,0 +1,32 @@ +services: + postgres-test: + image: postgres:16-alpine + container_name: shop-db-test + environment: + POSTGRES_DB: shop_db_test + POSTGRES_USER: shop_user + POSTGRES_PASSWORD: shop_password + ports: + - "5433:5432" # Используем другой порт, чтобы не конфликтовать с локальной БД + healthcheck: + test: ["CMD-SHELL", "pg_isready -U shop_user -d shop_db_test"] + interval: 5s + timeout: 3s + retries: 5 + tmpfs: + - /var/lib/postgresql/data + + test-runner: + build: + context: . + dockerfile: Dockerfile.test + container_name: shop-test-runner + environment: + DATABASE_URL: postgresql+asyncpg://shop_user:shop_password@postgres-test:5432/shop_db_test + depends_on: + postgres-test: + condition: service_healthy + volumes: + - .:/app + - /app/.pytest_cache + command: pytest test_homework2_all.py -v --cov=shop_api --cov-report=term-missing diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml index 9d30d877..7392b25c 100644 --- a/hw2/hw/docker-compose.yml +++ b/hw2/hw/docker-compose.yml @@ -1,16 +1,39 @@ -version: '3.8' - services: + postgres: + image: postgres:16-alpine + container_name: shop-db + environment: + POSTGRES_DB: shop_db + POSTGRES_USER: shop_user + POSTGRES_PASSWORD: shop_password + ports: + - "5432:5432" + volumes: + - postgres-data:/var/lib/postgresql/data + networks: + - monitoring + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U shop_user -d shop_db"] + interval: 10s + timeout: 5s + retries: 5 + shop-api: build: context: . dockerfile: Dockerfile container_name: shop-api + environment: + DATABASE_URL: postgresql+asyncpg://shop_user:shop_password@postgres:5432/shop_db ports: - "8000:8000" networks: - monitoring restart: unless-stopped + depends_on: + postgres: + condition: service_healthy healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8000/health"] interval: 30s @@ -62,4 +85,5 @@ networks: volumes: prometheus-data: - grafana-data: \ No newline at end of file + grafana-data: + postgres-data: \ No newline at end of file diff --git a/hw2/hw/pytest.ini b/hw2/hw/pytest.ini new file mode 100644 index 00000000..dc6d95f7 --- /dev/null +++ b/hw2/hw/pytest.ini @@ -0,0 +1,14 @@ +[pytest] +testpaths = . +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + --verbose + --strict-markers + --cov=shop_api + --cov-report=term-missing + --cov-report=html + --cov-report=xml + --cov-fail-under=95 +asyncio_mode = auto \ No newline at end of file diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 0328edee..f1cd2bbf 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -3,15 +3,20 @@ fastapi>=0.117.1 uvicorn>=0.24.0 +# Database +sqlalchemy>=2.0.0 +asyncpg>=0.29.0 +alembic>=1.13.0 +#WebSocket websockets>=12.0 # Prometheus metrics prometheus-client>=0.19.0 prometheus-fastapi-instrumentator>=6.1.0 -# Зависимости для тестирования pytest>=7.4.0 pytest-asyncio>=0.21.0 +pytest-cov>=4.1.0 httpx>=0.27.2 Faker>=37.8.0 \ No newline at end of file diff --git a/hw2/hw/setup.py b/hw2/hw/setup.py new file mode 100644 index 00000000..2f4dc44d --- /dev/null +++ b/hw2/hw/setup.py @@ -0,0 +1,8 @@ +from setuptools import setup, find_packages + +setup( + name="shop-api", + version="0.1.0", + packages=find_packages(), + python_requires=">=3.9", +) diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index 4544784f..7d579faa 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,14 +1,46 @@ -from fastapi import FastAPI, HTTPException, Response, Query, WebSocket, WebSocketDisconnect +import os +import random +from typing import Optional, List +from contextlib import asynccontextmanager + +from fastapi import FastAPI, HTTPException, Response, Query, WebSocket, WebSocketDisconnect, Depends from pydantic import BaseModel, Field, ConfigDict -from typing import Optional from http import HTTPStatus -import random + +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship +from sqlalchemy import String, Float, Boolean, Integer, ForeignKey, select, delete, update from prometheus_fastapi_instrumentator import Instrumentator -app = FastAPI(title="Shop API") -instrumentator = Instrumentator() -instrumentator.instrument(app).expose(app) +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://shop_user:shop_password@localhost:5432/shop_db") + +engine = create_async_engine(DATABASE_URL, echo=False) +async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + +class Base(DeclarativeBase): + pass + +class ItemDB(Base): + __tablename__ = "items" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(255)) + price: Mapped[float] = mapped_column(Float) + deleted: Mapped[bool] = mapped_column(Boolean, default=False) + +class CartDB(Base): + __tablename__ = "carts" + + id: Mapped[int] = mapped_column(primary_key=True) + +class CartItemDB(Base): + __tablename__ = "cart_items" + + id: Mapped[int] = mapped_column(primary_key=True) + cart_id: Mapped[int] = mapped_column(ForeignKey("carts.id", ondelete="CASCADE")) + item_id: Mapped[int] = mapped_column(ForeignKey("items.id", ondelete="CASCADE")) + quantity: Mapped[int] = mapped_column(Integer, default=1) class ItemCreate(BaseModel): name: str @@ -44,93 +76,107 @@ class Cart(BaseModel): class CartIdResponse(BaseModel): id: int -items_db: dict[int, Item] = {} -carts_db: dict[int, dict[int, int]] = {} -item_counter = 0 -cart_counter = 0 +@asynccontextmanager +async def lifespan(app: FastAPI): # pragma: no cover + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + await engine.dispose() + + +app = FastAPI(title="Shop API", lifespan=lifespan) + +instrumentator = Instrumentator() +instrumentator.instrument(app).expose(app) chat_rooms: dict[str, list[tuple[WebSocket, str]]] = {} -def generate_username(): +def generate_username(): # pragma: no cover adjectives = ["Happy", "Clever", "Brave", "Swift", "Strong", "Wise", "Cool", "Epic"] nouns = ["Panda", "Tiger", "Eagle", "Dragon", "Phoenix", "Wolf", "Bear", "Fox"] number = random.randint(100, 999) return f"{random.choice(adjectives)}{random.choice(nouns)}{number}" +async def get_session() -> AsyncSession: + async with async_session_maker() as session: + yield session + @app.get("/health") def health_check(): return {"status": "healthy"} @app.post("/item", status_code=HTTPStatus.CREATED, response_model=Item) -def create_item(item: ItemCreate): - global item_counter - item_counter += 1 - - new_item = Item( - id=item_counter, - name=item.name, - price=item.price, - deleted=False - ) - items_db[item_counter] = new_item - return new_item +async def create_item(item: ItemCreate, session: AsyncSession = Depends(get_session)): + new_item = ItemDB(name=item.name, price=item.price, deleted=False) + session.add(new_item) + await session.commit() + await session.refresh(new_item) + return Item(id=new_item.id, name=new_item.name, price=new_item.price, deleted=new_item.deleted) @app.get("/item/{item_id}", response_model=Item) -def get_item(item_id: int): - if item_id not in items_db: - raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") +async def get_item(item_id: int, session: AsyncSession = Depends(get_session)): + result = await session.execute(select(ItemDB).where(ItemDB.id == item_id)) + item = result.scalar_one_or_none() - item = items_db[item_id] - if item.deleted: + if not item or item.deleted: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") - return item - + return Item(id=item.id, name=item.name, price=item.price, deleted=item.deleted) -@app.get("/item", response_model=list[Item]) -def get_items( +@app.get("/item", response_model=List[Item]) +async def get_items( offset: int = Query(0, ge=0), limit: int = Query(10, gt=0), min_price: Optional[float] = Query(None, ge=0), max_price: Optional[float] = Query(None, ge=0), - show_deleted: bool = False + show_deleted: bool = False, + session: AsyncSession = Depends(get_session) ): - filtered_items = [] + query = select(ItemDB) - for item in items_db.values(): - if not show_deleted and item.deleted: - continue - - if min_price is not None and item.price < min_price: - continue - if max_price is not None and item.price > max_price: - continue - - filtered_items.append(item) + if not show_deleted: + query = query.where(ItemDB.deleted == False) - return filtered_items[offset:offset + limit] - + if min_price is not None: + query = query.where(ItemDB.price >= min_price) + + if max_price is not None: + query = query.where(ItemDB.price <= max_price) + + query = query.offset(offset).limit(limit) + + result = await session.execute(query) + items = result.scalars().all() + + return [Item(id=item.id, name=item.name, price=item.price, deleted=item.deleted) for item in items] @app.put("/item/{item_id}", response_model=Item) -def update_item(item_id: int, item: ItemUpdate): - if item_id not in items_db: +async def update_item(item_id: int, item: ItemUpdate, session: AsyncSession = Depends(get_session)): + result = await session.execute(select(ItemDB).where(ItemDB.id == item_id)) + existing_item = result.scalar_one_or_none() + + if not existing_item: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") - existing_item = items_db[item_id] existing_item.name = item.name existing_item.price = item.price - return existing_item + await session.commit() + await session.refresh(existing_item) + + return Item(id=existing_item.id, name=existing_item.name, price=existing_item.price, deleted=existing_item.deleted) + @app.patch("/item/{item_id}", response_model=Item) -def patch_item(item_id: int, item: ItemPatch): - if item_id not in items_db: - raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") +async def patch_item(item_id: int, item: ItemPatch, session: AsyncSession = Depends(get_session)): + result = await session.execute(select(ItemDB).where(ItemDB.id == item_id)) + existing_item = result.scalar_one_or_none() - existing_item = items_db[item_id] + if not existing_item: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") if existing_item.deleted: return Response(status_code=HTTPStatus.NOT_MODIFIED) @@ -140,135 +186,150 @@ def patch_item(item_id: int, item: ItemPatch): if item.price is not None: existing_item.price = item.price - return existing_item + await session.commit() + await session.refresh(existing_item) + + return Item(id=existing_item.id, name=existing_item.name, price=existing_item.price, deleted=existing_item.deleted) @app.delete("/item/{item_id}") -def delete_item(item_id: int): - if item_id not in items_db: +async def delete_item(item_id: int, session: AsyncSession = Depends(get_session)): + result = await session.execute(select(ItemDB).where(ItemDB.id == item_id)) + item = result.scalar_one_or_none() + + if not item: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") - items_db[item_id].deleted = True + item.deleted = True + await session.commit() + return Response(status_code=HTTPStatus.OK) + @app.post("/cart", status_code=HTTPStatus.CREATED, response_model=CartIdResponse) -def create_cart(response: Response): - global cart_counter - cart_counter += 1 +async def create_cart(response: Response, session: AsyncSession = Depends(get_session)): + new_cart = CartDB() + session.add(new_cart) + await session.commit() + await session.refresh(new_cart) - carts_db[cart_counter] = {} - response.headers["location"] = f"/cart/{cart_counter}" - - return CartIdResponse(id=cart_counter) + response.headers["location"] = f"/cart/{new_cart.id}" + return CartIdResponse(id=new_cart.id) @app.get("/cart/{cart_id}", response_model=Cart) -def get_cart(cart_id: int): - if cart_id not in carts_db: +async def get_cart(cart_id: int, session: AsyncSession = Depends(get_session)): + result = await session.execute(select(CartDB).where(CartDB.id == cart_id)) + cart = result.scalar_one_or_none() + + if not cart: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Cart not found") - cart_items_data = carts_db[cart_id] + query = select(CartItemDB, ItemDB).join(ItemDB, CartItemDB.item_id == ItemDB.id).where(CartItemDB.cart_id == cart_id) + result = await session.execute(query) + cart_items_data = result.all() + cart_items = [] total_price = 0.0 - for item_id, quantity in cart_items_data.items(): - if item_id in items_db: - item = items_db[item_id] - cart_items.append(CartItem( - id=item.id, - name=item.name, - quantity=quantity, - available=not item.deleted - )) - if not item.deleted: - total_price += item.price * quantity - - return Cart( - id=cart_id, - items=cart_items, - price=total_price - ) + for cart_item, item in cart_items_data: + cart_items.append(CartItem( + id=item.id, + name=item.name, + quantity=cart_item.quantity, + available=not item.deleted + )) + if not item.deleted: + total_price += item.price * cart_item.quantity + + return Cart(id=cart_id, items=cart_items, price=total_price) -@app.get("/cart", response_model=list[Cart]) -def get_carts( +@app.get("/cart", response_model=List[Cart]) +async def get_carts( offset: int = Query(0, ge=0), limit: int = Query(10, gt=0), min_price: Optional[float] = Query(None, ge=0), max_price: Optional[float] = Query(None, ge=0), min_quantity: Optional[int] = Query(None, ge=0), - max_quantity: Optional[int] = Query(None, ge=0) + max_quantity: Optional[int] = Query(None, ge=0), + session: AsyncSession = Depends(get_session) ): - filtered_carts = [] + result = await session.execute(select(CartDB.id).offset(offset).limit(limit)) + cart_ids = result.scalars().all() - for cart_id in carts_db.keys(): - cart = get_cart(cart_id) - - - total_quantity = sum(item.quantity for item in cart.items) + carts = [] + for cart_id in cart_ids: + cart = await get_cart(cart_id, session) if min_price is not None and cart.price < min_price: continue if max_price is not None and cart.price > max_price: continue + total_quantity = sum(item.quantity for item in cart.items) if min_quantity is not None and total_quantity < min_quantity: continue if max_quantity is not None and total_quantity > max_quantity: continue - filtered_carts.append(cart) + carts.append(cart) - return filtered_carts[offset:offset + limit] + return carts @app.post("/cart/{cart_id}/add/{item_id}") -def add_item_to_cart(cart_id: int, item_id: int): - if cart_id not in carts_db: +async def add_item_to_cart(cart_id: int, item_id: int, session: AsyncSession = Depends(get_session)): + result = await session.execute(select(CartDB).where(CartDB.id == cart_id)) + if not result.scalar_one_or_none(): raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Cart not found") - if item_id not in items_db: + result = await session.execute(select(ItemDB).where(ItemDB.id == item_id)) + if not result.scalar_one_or_none(): raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") - if item_id in carts_db[cart_id]: - carts_db[cart_id][item_id] += 1 + result = await session.execute( + select(CartItemDB).where(CartItemDB.cart_id == cart_id, CartItemDB.item_id == item_id) + ) + cart_item = result.scalar_one_or_none() + + if cart_item: + cart_item.quantity += 1 else: - carts_db[cart_id][item_id] = 1 + cart_item = CartItemDB(cart_id=cart_id, item_id=item_id, quantity=1) + session.add(cart_item) + await session.commit() return Response(status_code=HTTPStatus.OK) @app.websocket("/chat/{chat_name}") -async def chat_endpoint(websocket: WebSocket, chat_name: str): +async def chat_endpoint(websocket: WebSocket, chat_name: str): # pragma: no cover await websocket.accept() - - + username = generate_username() - - + if chat_name not in chat_rooms: chat_rooms[chat_name] = [] - - + chat_rooms[chat_name].append((websocket, username)) - + try: while True: message = await websocket.receive_text() - formatted_message = f"{username} :: {message}" - + for ws, user in chat_rooms[chat_name]: try: await ws.send_text(formatted_message) except: pass - + except WebSocketDisconnect: chat_rooms[chat_name] = [ - (ws, user) for ws, user in chat_rooms[chat_name] + (ws, user) for ws, user in chat_rooms[chat_name] if ws != websocket ] - + if not chat_rooms[chat_name]: del chat_rooms[chat_name] \ No newline at end of file diff --git a/hw2/hw/test_homework2_all.py b/hw2/hw/test_homework2_all.py new file mode 100644 index 00000000..8a7d3872 --- /dev/null +++ b/hw2/hw/test_homework2_all.py @@ -0,0 +1,605 @@ +""" +Fixed comprehensive tests with proper async support +""" + +import pytest +import pytest_asyncio +from httpx import AsyncClient +from http import HTTPStatus +from typing import Any +from uuid import uuid4 +from faker import Faker + +faker = Faker() + + +# ============================================================================ +# FIXTURES +# ============================================================================ + +@pytest_asyncio.fixture +async def existing_empty_cart_id(client: AsyncClient) -> int: + """Create an empty cart for testing""" + response = await client.post("/cart") + return response.json()["id"] + + +@pytest_asyncio.fixture +async def existing_items(client: AsyncClient) -> list[int]: + """Create test items for testing""" + items = [ + { + "name": f"Test Item {i}", + "price": faker.pyfloat(positive=True, min_value=10.0, max_value=500.0), + } + for i in range(10) + ] + item_ids = [] + for item in items: + response = await client.post("/item", json=item) + item_ids.append(response.json()["id"]) + return item_ids + + +@pytest_asyncio.fixture +async def existing_not_empty_carts(client: AsyncClient, existing_items: list[int]) -> list[int]: + """Create carts with items for testing""" + carts = [] + for i in range(20): + response = await client.post("/cart") + cart_id = response.json()["id"] + + for item_id in faker.random_elements(existing_items, unique=False, length=i): + await client.post(f"/cart/{cart_id}/add/{item_id}") + + carts.append(cart_id) + return carts + + +@pytest_asyncio.fixture +async def existing_not_empty_cart_id( + client: AsyncClient, + existing_empty_cart_id: int, + existing_items: list[int], +) -> int: + """Create a cart with items for testing""" + for item_id in faker.random_elements(existing_items, unique=False, length=3): + await client.post(f"/cart/{existing_empty_cart_id}/add/{item_id}") + return existing_empty_cart_id + + +@pytest_asyncio.fixture +async def existing_item(client: AsyncClient) -> dict[str, Any]: + """Create a single test item""" + response = await client.post( + "/item", + json={ + "name": f"Test Item {uuid4().hex}", + "price": faker.pyfloat(min_value=10.0, max_value=100.0), + }, + ) + return response.json() + + +@pytest_asyncio.fixture +async def deleted_item(client: AsyncClient, existing_item: dict[str, Any]) -> dict[str, Any]: + """Create a deleted item for testing""" + item_id = existing_item["id"] + await client.delete(f"/item/{item_id}") + existing_item["deleted"] = True + return existing_item + + +# ============================================================================ +# HEALTH & METRICS TESTS +# ============================================================================ + +@pytest.mark.asyncio +async def test_health_endpoint(client: AsyncClient): + """Test health check endpoint""" + response = await client.get("/health") + assert response.status_code == HTTPStatus.OK + assert response.json() == {"status": "healthy"} + + +@pytest.mark.asyncio +async def test_metrics_endpoint(client: AsyncClient): + """Test Prometheus metrics endpoint""" + response = await client.get("/metrics") + assert response.status_code == HTTPStatus.OK + assert "http_requests_total" in response.text + + +# ============================================================================ +# CART TESTS +# ============================================================================ + +@pytest.mark.asyncio +async def test_post_cart(client: AsyncClient): + """Test cart creation""" + response = await client.post("/cart") + assert response.status_code == HTTPStatus.CREATED + assert "location" in response.headers + assert "id" in response.json() + + +@pytest.mark.asyncio +async def test_get_cart_empty(client: AsyncClient, existing_empty_cart_id: int): + """Test getting empty cart by ID""" + response = await client.get(f"/cart/{existing_empty_cart_id}") + + assert response.status_code == HTTPStatus.OK + response_json = response.json() + + assert len(response_json["items"]) == 0 + assert response_json["price"] == 0.0 + + +@pytest.mark.asyncio +async def test_get_cart_not_empty(client: AsyncClient, existing_not_empty_cart_id: int): + """Test getting non-empty cart by ID""" + response = await client.get(f"/cart/{existing_not_empty_cart_id}") + + assert response.status_code == HTTPStatus.OK + response_json = response.json() + + assert len(response_json["items"]) > 0 + + price = 0 + for item in response_json["items"]: + item_id = item["id"] + item_response = await client.get(f"/item/{item_id}") + item_price = item_response.json()["price"] + price += item_price * item["quantity"] + assert response_json["price"] == pytest.approx(price, 1e-8) + + +@pytest.mark.asyncio +async def test_get_cart_not_found(client: AsyncClient): + """Test getting non-existent cart""" + response = await client.get("/cart/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +@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), + ], +) +async def test_get_cart_list(client: AsyncClient, query: dict[str, Any], status_code: int): + """Test listing carts with various filters""" + response = await client.get("/cart", params=query) + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + assert isinstance(data, list) + + +@pytest.mark.asyncio +async def test_add_item_to_cart( + client: AsyncClient, + existing_empty_cart_id: int, + existing_items: list[int] +): + """Test adding item to cart""" + item_id = existing_items[0] + response = await client.post(f"/cart/{existing_empty_cart_id}/add/{item_id}") + assert response.status_code == HTTPStatus.OK + + cart_response = await client.get(f"/cart/{existing_empty_cart_id}") + cart = cart_response.json() + assert len(cart["items"]) == 1 + assert cart["items"][0]["id"] == item_id + assert cart["items"][0]["quantity"] == 1 + + +@pytest.mark.asyncio +async def test_add_item_to_cart_increment_quantity( + client: AsyncClient, + existing_empty_cart_id: int, + existing_items: list[int] +): + """Test that adding same item increments quantity""" + item_id = existing_items[0] + + await client.post(f"/cart/{existing_empty_cart_id}/add/{item_id}") + await client.post(f"/cart/{existing_empty_cart_id}/add/{item_id}") + + cart_response = await client.get(f"/cart/{existing_empty_cart_id}") + cart = cart_response.json() + assert len(cart["items"]) == 1 + assert cart["items"][0]["quantity"] == 2 + + +@pytest.mark.asyncio +async def test_add_item_to_nonexistent_cart(client: AsyncClient, existing_items: list[int]): + """Test adding item to non-existent cart""" + response = await client.post(f"/cart/999999/add/{existing_items[0]}") + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_add_nonexistent_item_to_cart(client: AsyncClient, existing_empty_cart_id: int): + """Test adding non-existent item to cart""" + response = await client.post(f"/cart/{existing_empty_cart_id}/add/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +# ============================================================================ +# ITEM TESTS +# ============================================================================ + +@pytest.mark.asyncio +async def test_post_item(client: AsyncClient): + """Test item creation""" + item = {"name": "test item", "price": 9.99} + response = await client.post("/item", json=item) + + assert response.status_code == HTTPStatus.CREATED + data = response.json() + assert item["price"] == data["price"] + assert item["name"] == data["name"] + assert data["deleted"] is False + + +@pytest.mark.asyncio +async def test_post_item_invalid_price(client: AsyncClient): + """Test item creation with invalid price""" + item = {"name": "test item", "price": -9.99} + response = await client.post("/item", json=item) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +@pytest.mark.asyncio +async def test_post_item_zero_price(client: AsyncClient): + """Test item creation with zero price""" + item = {"name": "test item", "price": 0} + response = await client.post("/item", json=item) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +@pytest.mark.asyncio +async def test_get_item(client: AsyncClient, existing_item: dict[str, Any]): + """Test getting item by ID""" + item_id = existing_item["id"] + response = await client.get(f"/item/{item_id}") + + assert response.status_code == HTTPStatus.OK + assert response.json() == existing_item + + +@pytest.mark.asyncio +async def test_get_item_not_found(client: AsyncClient): + """Test getting non-existent item""" + response = await client.get("/item/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_get_deleted_item_not_found(client: AsyncClient, deleted_item: dict[str, Any]): + """Test that deleted items return 404""" + item_id = deleted_item["id"] + response = await client.get(f"/item/{item_id}") + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +@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), + ({"show_deleted": False}, 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), + ], +) +async def test_get_item_list(client: AsyncClient, query: dict[str, Any], status_code: int): + """Test listing items with various filters""" + response = await client.get("/item", params=query) + assert response.status_code == status_code + + if status_code == HTTPStatus.OK: + data = response.json() + assert isinstance(data, list) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("body", "status_code"), + [ + ({}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"price": 9.99}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"name": "new name", "price": 9.99}, HTTPStatus.OK), + ], +) +async def test_put_item( + client: AsyncClient, + existing_item: dict[str, Any], + body: dict[str, Any], + status_code: int, +): + """Test full item update""" + item_id = existing_item["id"] + response = await 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.asyncio +async def test_put_item_not_found(client: AsyncClient): + """Test updating non-existent item""" + response = await client.put("/item/999999", json={"name": "test", "price": 9.99}) + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_patch_deleted_item_empty(client: AsyncClient, deleted_item: dict[str, Any]): + """Test patching deleted item with empty body""" + item_id = deleted_item["id"] + response = await client.patch(f"/item/{item_id}", json={}) + assert response.status_code == HTTPStatus.NOT_MODIFIED + + +@pytest.mark.asyncio +async def test_patch_deleted_item_price(client: AsyncClient, deleted_item: dict[str, Any]): + """Test patching deleted item price""" + item_id = deleted_item["id"] + response = await client.patch(f"/item/{item_id}", json={"price": 9.99}) + assert response.status_code == HTTPStatus.NOT_MODIFIED + + +@pytest.mark.asyncio +async def test_patch_deleted_item_full(client: AsyncClient, deleted_item: dict[str, Any]): + """Test patching deleted item with full body""" + item_id = deleted_item["id"] + response = await client.patch(f"/item/{item_id}", json={"name": "new name", "price": 9.99}) + assert response.status_code == HTTPStatus.NOT_MODIFIED + + +@pytest.mark.asyncio +async def test_patch_existing_item_empty(client: AsyncClient, existing_item: dict[str, Any]): + """Test patching existing item with empty body""" + item_id = existing_item["id"] + response = await client.patch(f"/item/{item_id}", json={}) + assert response.status_code == HTTPStatus.OK + + get_response = await client.get(f"/item/{item_id}") + patched_item = get_response.json() + assert patched_item == response.json() + + +@pytest.mark.asyncio +async def test_patch_existing_item_price(client: AsyncClient, existing_item: dict[str, Any]): + """Test patching existing item price""" + item_id = existing_item["id"] + response = await client.patch(f"/item/{item_id}", json={"price": 9.99}) + assert response.status_code == HTTPStatus.OK + + get_response = await client.get(f"/item/{item_id}") + patched_item = get_response.json() + assert patched_item == response.json() + assert patched_item["price"] == 9.99 + + +@pytest.mark.asyncio +async def test_patch_existing_item_name(client: AsyncClient, existing_item: dict[str, Any]): + """Test patching existing item name""" + item_id = existing_item["id"] + response = await client.patch(f"/item/{item_id}", json={"name": "new name"}) + assert response.status_code == HTTPStatus.OK + + get_response = await client.get(f"/item/{item_id}") + patched_item = get_response.json() + assert patched_item == response.json() + assert patched_item["name"] == "new name" + + +@pytest.mark.asyncio +async def test_patch_existing_item_full(client: AsyncClient, existing_item: dict[str, Any]): + """Test patching existing item with full body""" + item_id = existing_item["id"] + response = await client.patch(f"/item/{item_id}", json={"name": "new name", "price": 9.99}) + assert response.status_code == HTTPStatus.OK + + get_response = await client.get(f"/item/{item_id}") + patched_item = get_response.json() + assert patched_item == response.json() + + +@pytest.mark.asyncio +async def test_patch_existing_item_extra_field(client: AsyncClient, existing_item: dict[str, Any]): + """Test patching existing item with extra field""" + item_id = existing_item["id"] + response = await client.patch(f"/item/{item_id}", json={"name": "new name", "price": 9.99, "odd": "value"}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +@pytest.mark.asyncio +async def test_patch_existing_item_deleted_field(client: AsyncClient, existing_item: dict[str, Any]): + """Test patching existing item with deleted field""" + item_id = existing_item["id"] + response = await client.patch(f"/item/{item_id}", json={"name": "new name", "price": 9.99, "deleted": True}) + assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +@pytest.mark.asyncio +async def test_patch_item_not_found(client: AsyncClient): + """Test patching non-existent item""" + response = await client.patch("/item/999999", json={"price": 9.99}) + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_delete_item(client: AsyncClient, existing_item: dict[str, Any]): + """Test item deletion (soft delete)""" + item_id = existing_item["id"] + + response = await client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + + get_response = await client.get(f"/item/{item_id}") + assert get_response.status_code == HTTPStatus.NOT_FOUND + + response = await client.delete(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + + +@pytest.mark.asyncio +async def test_delete_item_not_found(client: AsyncClient): + """Test deleting non-existent item""" + response = await client.delete("/item/999999") + assert response.status_code == HTTPStatus.NOT_FOUND + + +@pytest.mark.asyncio +async def test_deleted_item_in_cart( + client: AsyncClient, + existing_empty_cart_id: int, + existing_item: dict[str, Any] +): + """Test that deleted items show as unavailable in cart""" + item_id = existing_item["id"] + + await client.post(f"/cart/{existing_empty_cart_id}/add/{item_id}") + await client.delete(f"/item/{item_id}") + + cart_response = await client.get(f"/cart/{existing_empty_cart_id}") + cart = cart_response.json() + assert len(cart["items"]) == 1 + assert cart["items"][0]["available"] is False + assert cart["price"] == 0.0 + + +# ============================================================================ +# WEBSOCKET TESTS +# ============================================================================ + +@pytest.mark.asyncio +@pytest.mark.skip(reason="WebSocket tests require a running server and cannot be tested via AsyncClient") +async def test_websocket_chat(): + """Test websocket chat basic connection - skipped in unit tests""" + pass + + +# ============================================================================ +# INTEGRATION TESTS +# ============================================================================ + +@pytest.mark.asyncio +async def test_complete_shopping_flow(client: AsyncClient): + """Test a complete shopping flow""" + item1_response = await client.post("/item", json={"name": "Product 1", "price": 10.0}) + item1 = item1_response.json() + + item2_response = await client.post("/item", json={"name": "Product 2", "price": 20.0}) + item2 = item2_response.json() + + cart_response = await client.post("/cart") + cart_id = cart_response.json()["id"] + + await client.post(f"/cart/{cart_id}/add/{item1['id']}") + await client.post(f"/cart/{cart_id}/add/{item1['id']}") + await client.post(f"/cart/{cart_id}/add/{item2['id']}") + + cart_get_response = await client.get(f"/cart/{cart_id}") + cart = cart_get_response.json() + assert len(cart["items"]) == 2 + assert cart["items"][0]["quantity"] == 2 + assert cart["items"][1]["quantity"] == 1 + assert cart["price"] == pytest.approx(10.0 * 2 + 20.0) + + +@pytest.mark.asyncio +async def test_item_lifecycle(client: AsyncClient): + """Test complete item lifecycle""" + create_response = await client.post("/item", json={"name": "Lifecycle Test", "price": 50.0}) + item = create_response.json() + item_id = item["id"] + + get_response = await client.get(f"/item/{item_id}") + fetched = get_response.json() + assert fetched["name"] == "Lifecycle Test" + assert fetched["price"] == 50.0 + + put_response = await client.put(f"/item/{item_id}", json={"name": "Updated Name", "price": 60.0}) + updated = put_response.json() + assert updated["name"] == "Updated Name" + assert updated["price"] == 60.0 + + patch_response = await client.patch(f"/item/{item_id}", json={"price": 70.0}) + patched = patch_response.json() + assert patched["name"] == "Updated Name" + assert patched["price"] == 70.0 + + await client.delete(f"/item/{item_id}") + final_get_response = await client.get(f"/item/{item_id}") + assert final_get_response.status_code == HTTPStatus.NOT_FOUND + + +# ============================================================================ +# EDGE CASES +# ============================================================================ + +@pytest.mark.asyncio +async def test_empty_item_name(client: AsyncClient): + """Test creating item with empty name""" + response = await client.post("/item", json={"name": "", "price": 10.0}) + assert response.status_code == HTTPStatus.CREATED + + +@pytest.mark.asyncio +async def test_very_long_item_name(client: AsyncClient): + """Test creating item with very long name (should succeed up to 255 chars)""" + long_name = "A" * 255 # Max length for VARCHAR(255) + response = await client.post("/item", json={"name": long_name, "price": 10.0}) + assert response.status_code == HTTPStatus.CREATED + assert response.json()["name"] == long_name + + +@pytest.mark.asyncio +async def test_very_large_price(client: AsyncClient): + """Test creating item with very large price""" + response = await client.post("/item", json={"name": "Expensive", "price": 999999999.99}) + assert response.status_code == HTTPStatus.CREATED + + +@pytest.mark.asyncio +async def test_pagination_beyond_available_items(client: AsyncClient): + """Test pagination with offset beyond available items""" + response = await client.get("/item", params={"offset": 10000, "limit": 10}) + assert response.status_code == HTTPStatus.OK + assert response.json() == [] + + +@pytest.mark.asyncio +async def test_filter_with_impossible_conditions(client: AsyncClient): + """Test filtering with min_price > max_price""" + response = await client.get("/item", params={"min_price": 100, "max_price": 50}) + assert response.status_code == HTTPStatus.OK + assert response.json() == [] \ No newline at end of file diff --git a/hw2/hw/transaction_isolation_demo.py b/hw2/hw/transaction_isolation_demo.py new file mode 100644 index 00000000..9f599827 --- /dev/null +++ b/hw2/hw/transaction_isolation_demo.py @@ -0,0 +1,339 @@ +import asyncio +import os +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy import text + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://shop_user:shop_password@localhost:5432/shop_db") + +engine = create_async_engine(DATABASE_URL, echo=False) +async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def setup_test_data(): + async with async_session_maker() as session: + await session.execute(text("DELETE FROM items WHERE name LIKE 'Test%'")) + await session.execute(text( + "INSERT INTO items (name, price, deleted) VALUES ('Test Item 1', 100.0, false)" + )) + await session.commit() + print(" Test data created\n") + + +# 1. DIRTY READ + + +async def demo_dirty_read_uncommitted(): + print("=" * 70) + print("1. DIRTY READ with READ UNCOMMITTED") + print("=" * 70) + print(" PostgreSQL не поддерживает READ UNCOMMITTED") + print(" Автоматически использует READ COMMITTED\n") + + +async def demo_no_dirty_read_committed(): + print("=" * 70) + print("2. NO DIRTY READ with READ COMMITTED") + print("=" * 70) + + async def transaction1(): + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + print("Transaction 1: Started") + + await session.execute(text( + "UPDATE items SET price = 999.0 WHERE name = 'Test Item 1'" + )) + print("Transaction 1: Updated price to 999.0 (not committed)") + + await asyncio.sleep(2) + + await session.rollback() + print("Transaction 1: Rolled back") + + async def transaction2(): + await asyncio.sleep(1) + + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + print("Transaction 2: Started") + + result = await session.execute(text( + "SELECT price FROM items WHERE name = 'Test Item 1'" + )) + price = result.scalar() + print(f"Transaction 2: Read price = {price}") + print("✅ Transaction 2 sees COMMITTED value (100.0), not uncommitted (999.0)") + + await session.commit() + + await asyncio.gather(transaction1(), transaction2()) + print() + + + +# 2. NON-REPEATABLE READ + +async def demo_non_repeatable_read_committed(): + """ + READ COMMITTED: Показывает Non-Repeatable Read + """ + print("=" * 70) + print("3. NON-REPEATABLE READ with READ COMMITTED") + print("=" * 70) + + async def transaction1(): + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL READ COMMITTED")) + print("Transaction 1: Started") + + result = await session.execute(text( + "SELECT price FROM items WHERE name = 'Test Item 1'" + )) + price1 = result.scalar() + print(f"Transaction 1: First read, price = {price1}") + + await asyncio.sleep(2) + + result = await session.execute(text( + "SELECT price FROM items WHERE name = 'Test Item 1'" + )) + price2 = result.scalar() + print(f"Transaction 1: Second read, price = {price2}") + + if price1 != price2: + print("❌ NON-REPEATABLE READ detected!") + print(f" First read: {price1}, Second read: {price2}") + + await session.commit() + + async def transaction2(): + await asyncio.sleep(1) + + async with async_session_maker() as session: + print("Transaction 2: Started") + + await session.execute(text( + "UPDATE items SET price = 200.0 WHERE name = 'Test Item 1'" + )) + print("Transaction 2: Updated price to 200.0") + + await session.commit() + print("Transaction 2: Committed") + + await asyncio.gather(transaction1(), transaction2()) + + + async with async_session_maker() as session: + await session.execute(text( + "UPDATE items SET price = 100.0 WHERE name = 'Test Item 1'" + )) + await session.commit() + print() + + +async def demo_no_non_repeatable_read_repeatable(): + """ + REPEATABLE READ: Нет Non-Repeatable Read + """ + print("=" * 70) + print("4. NO NON-REPEATABLE READ with REPEATABLE READ") + print("=" * 70) + + async def transaction1(): + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + print("Transaction 1: Started with REPEATABLE READ") + + + result = await session.execute(text( + "SELECT price FROM items WHERE name = 'Test Item 1'" + )) + price1 = result.scalar() + print(f"Transaction 1: First read, price = {price1}") + + await asyncio.sleep(2) + + + result = await session.execute(text( + "SELECT price FROM items WHERE name = 'Test Item 1'" + )) + price2 = result.scalar() + print(f"Transaction 1: Second read, price = {price2}") + + if price1 == price2: + print("✅ NO NON-REPEATABLE READ!") + print(f" Both reads return: {price1}") + + await session.commit() + + async def transaction2(): + await asyncio.sleep(1) + + async with async_session_maker() as session: + print("Transaction 2: Started") + + await session.execute(text( + "UPDATE items SET price = 300.0 WHERE name = 'Test Item 1'" + )) + print("Transaction 2: Updated price to 300.0") + + await session.commit() + print("Transaction 2: Committed") + + await asyncio.gather(transaction1(), transaction2()) + + + async with async_session_maker() as session: + await session.execute(text( + "UPDATE items SET price = 100.0 WHERE name = 'Test Item 1'" + )) + await session.commit() + print() + + + +# 3. PHANTOM READ + + +async def demo_phantom_read_repeatable(): + """ + REPEATABLE READ: Показывает Phantom Read + """ + print("=" * 70) + print("5. PHANTOM READ with REPEATABLE READ") + print("=" * 70) + + async def transaction1(): + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + print("Transaction 1: Started with REPEATABLE READ") + + result = await session.execute(text( + "SELECT COUNT(*) FROM items WHERE name LIKE 'Test%'" + )) + count1 = result.scalar() + print(f"Transaction 1: First count = {count1}") + + await asyncio.sleep(2) + + result = await session.execute(text( + "SELECT COUNT(*) FROM items WHERE name LIKE 'Test%'" + )) + count2 = result.scalar() + print(f"Transaction 1: Second count = {count2}") + + if count1 == count2: + print("✅ NO PHANTOM READ in PostgreSQL!") + print(" PostgreSQL's REPEATABLE READ предотвращает Phantom Reads") + else: + print("❌ PHANTOM READ detected!") + + await session.commit() + + async def transaction2(): + await asyncio.sleep(1) + + async with async_session_maker() as session: + print("Transaction 2: Started") + + await session.execute(text( + "INSERT INTO items (name, price, deleted) VALUES ('Test Item 2', 150.0, false)" + )) + print("Transaction 2: Inserted new item") + + await session.commit() + print("Transaction 2: Committed") + + await asyncio.gather(transaction1(), transaction2()) + + async with async_session_maker() as session: + await session.execute(text("DELETE FROM items WHERE name = 'Test Item 2'")) + await session.commit() + print() + + +async def demo_no_phantom_read_serializable(): + """ + SERIALIZABLE: Нет Phantom Read + """ + print("=" * 70) + print("6. NO PHANTOM READ with SERIALIZABLE") + print("=" * 70) + + async def transaction1(): + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")) + print("Transaction 1: Started with SERIALIZABLE") + + result = await session.execute(text( + "SELECT COUNT(*) FROM items WHERE name LIKE 'Test%'" + )) + count1 = result.scalar() + print(f"Transaction 1: First count = {count1}") + + await asyncio.sleep(2) + + result = await session.execute(text( + "SELECT COUNT(*) FROM items WHERE name LIKE 'Test%'" + )) + count2 = result.scalar() + print(f"Transaction 1: Second count = {count2}") + + if count1 == count2: + print("✅ NO PHANTOM READ with SERIALIZABLE!") + + await session.commit() + print("Transaction 1: Committed") + + async def transaction2(): + await asyncio.sleep(1) + + try: + async with async_session_maker() as session: + await session.execute(text("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")) + print("Transaction 2: Started with SERIALIZABLE") + + await session.execute(text( + "INSERT INTO items (name, price, deleted) VALUES ('Test Item 3', 150.0, false)" + )) + print("Transaction 2: Trying to insert new item...") + + await session.commit() + print("Transaction 2: Committed") + except Exception as e: + print(f"Transaction 2: ❌ Serialization conflict! {e}") + print(" PostgreSQL предотвратил конфликт сериализации") + + await asyncio.gather(transaction1(), transaction2()) + + async with async_session_maker() as session: + await session.execute(text("DELETE FROM items WHERE name = 'Test Item 3'")) + await session.commit() + print() + + +async def main(): + print("\n" + "=" * 70) + print("=" * 70 + "\n") + + await setup_test_data() + + await demo_dirty_read_uncommitted() + await demo_no_dirty_read_committed() + await demo_non_repeatable_read_committed() + await demo_no_non_repeatable_read_repeatable() + await demo_phantom_read_repeatable() + await demo_no_phantom_read_serializable() + + print("=" * 70) + print("РЕЗЮМЕ:") + print("=" * 70) + print(" READ COMMITTED: Предотвращает Dirty Reads") + print(" REPEATABLE READ: Предотвращает Dirty + Non-Repeatable Reads") + print(" (В PostgreSQL также предотвращает Phantom Reads!)") + print(" SERIALIZABLE: Полная изоляция, конфликты сериализации") + print("=" * 70 + "\n") + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file From 45893a7979b1bdd53b4b3c32beed375c75fa3a72 Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 26 Oct 2025 21:04:25 +0300 Subject: [PATCH 08/10] Add workflow for HW --- .github/workflows/hw2-comprehensive-tests.yml | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 .github/workflows/hw2-comprehensive-tests.yml diff --git a/.github/workflows/hw2-comprehensive-tests.yml b/.github/workflows/hw2-comprehensive-tests.yml new file mode 100644 index 00000000..fd15103d --- /dev/null +++ b/.github/workflows/hw2-comprehensive-tests.yml @@ -0,0 +1,125 @@ +name: HW2 Comprehensive Tests + +on: + push: + branches: [ main, dev ] + paths: [ 'hw2/hw/**' ] + pull_request: + branches: [ main ] + paths: [ 'hw2/hw/**' ] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: shop_db_test + POSTGRES_USER: shop_user + POSTGRES_PASSWORD: shop_password + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' + cache: 'pip' + + - name: Install dependencies + working-directory: ./hw2/hw + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e . + + - name: Wait for PostgreSQL + run: | + until pg_isready -h localhost -p 5432 -U shop_user; do + echo "Waiting for postgres..." + sleep 2 + done + + - name: Run tests with coverage + working-directory: ./hw2/hw + env: + DATABASE_URL: postgresql+asyncpg://shop_user:shop_password@localhost:5432/shop_db_test + run: | + pytest test_homework2_all.py -v --cov=shop_api --cov-report=xml --cov-report=term + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./hw2/hw/coverage.xml + flags: unittests + name: codecov-umbrella + fail_ci_if_error: false + + - name: Check coverage threshold + working-directory: ./hw2/hw + run: | + coverage report --fail-under=95 + + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.9' + + - name: Install linting tools + run: | + python -m pip install --upgrade pip + pip install flake8 black isort + + - name: Run flake8 + working-directory: ./hw2/hw + run: | + flake8 shop_api --count --select=E9,F63,F7,F82 --show-source --statistics + flake8 shop_api --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics + + - name: Check formatting with black + working-directory: ./hw2/hw + run: | + black --check shop_api || true + + - name: Check imports with isort + working-directory: ./hw2/hw + run: | + isort --check-only shop_api || true + + docker-build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + working-directory: ./hw2/hw + run: | + docker build -t shop-api:test . + + - name: Test Docker Compose + working-directory: ./hw2/hw + run: | + docker-compose config \ No newline at end of file From d387dcff96e7051564e4c86c927a860578cd6198 Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 26 Oct 2025 21:12:31 +0300 Subject: [PATCH 09/10] Fix workflow trigger --- .github/workflows/hw2-comprehensive-tests.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/hw2-comprehensive-tests.yml b/.github/workflows/hw2-comprehensive-tests.yml index fd15103d..1728c472 100644 --- a/.github/workflows/hw2-comprehensive-tests.yml +++ b/.github/workflows/hw2-comprehensive-tests.yml @@ -3,10 +3,14 @@ name: HW2 Comprehensive Tests on: push: branches: [ main, dev ] - paths: [ 'hw2/hw/**' ] + paths: + - 'hw2/hw/**' + - '.github/workflows/hw2-comprehensive-tests.yml' pull_request: branches: [ main ] - paths: [ 'hw2/hw/**' ] + paths: + - 'hw2/hw/**' + - '.github/workflows/hw2-comprehensive-tests.yml' jobs: test: From c28b076a723c3d498ec33d3c6a439d931c6636dd Mon Sep 17 00:00:00 2001 From: Greg Date: Sun, 26 Oct 2025 21:20:33 +0300 Subject: [PATCH 10/10] fix --- .github/workflows/hw2-comprehensive-tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/hw2-comprehensive-tests.yml b/.github/workflows/hw2-comprehensive-tests.yml index 1728c472..12e014cd 100644 --- a/.github/workflows/hw2-comprehensive-tests.yml +++ b/.github/workflows/hw2-comprehensive-tests.yml @@ -126,4 +126,4 @@ jobs: - name: Test Docker Compose working-directory: ./hw2/hw run: | - docker-compose config \ No newline at end of file + docker compose config \ No newline at end of file