From e4f7e1754a4bf51db91f8842f37fdb13654d8239 Mon Sep 17 00:00:00 2001 From: Dmitry Redko Date: Mon, 22 Sep 2025 23:07:47 +0300 Subject: [PATCH 1/5] Implement ASGI application with fibonacci, factorial, and mean endpoints --- hw1/app.py | 117 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 116 insertions(+), 1 deletion(-) diff --git a/hw1/app.py b/hw1/app.py index 6107b870..d27d538c 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,6 +1,105 @@ from typing import Any, Awaitable, Callable +import json +import math +from urllib.parse import parse_qs +async def respond(send, status: int, body: dict[str, Any]): + await send({ + "type": "http.response.start", + "status": status, + "headers": [(b"content-type", b"application/json")], + }) + await send({"type": "http.response.body", "body": json.dumps(body).encode()}) +async def read_http_body(receive) -> bytes: + chunks: list[bytes] = [] + more = True + while more: + message = await receive() + if message["type"] != "http.request": + continue + chunks.append(message.get("body", b"")) + more = message.get("more_body", False) + return b"".join(chunks) + +def parse_query(scope) -> dict[str, list[str]]: + return parse_qs(scope.get("query_string", b"").decode()) + +async def handle_fibonacci(method: str, path: str, send): + if method != "GET": + return await respond(send, 422, {"error": "Unsupported method"}) + param = path[len("/fibonacci/") :] + if not param: + return await respond(send, 422, {"error": "Invalid n"}) + try: + n = int(param) + except ValueError: + return await respond(send, 422, {"error": "Invalid n"}) + if n < 0: + return await respond(send, 400, {"error": "n must be non-negative"}) + a, b = 0, 1 + for _ in range(n): + a, b = b, a + b + return await respond(send, 200, {"result": a}) + +async def handle_factorial(method: str, query: dict[str, list[str]], send): + if method != "GET": + return await respond(send, 422, {"error": "Unsupported method"}) + raw = query.get("n") + if not raw or raw[0] == "": + return await respond(send, 422, {"error": "Invalid n"}) + try: + n = int(raw[0]) + except ValueError: + return await respond(send, 422, {"error": "Invalid n"}) + if n < 0: + return await respond(send, 400, {"error": "n must be non-negative"}) + return await respond(send, 200, {"result": math.factorial(n)}) + +async def handle_mean(method: str, query: dict[str, list[str]], receive, send): + if method != "GET": + return await respond(send, 422, {"error": "Unsupported method"}) + body = await read_http_body(receive) + + if body: + try: + data = json.loads(body.decode() or "null") + except json.JSONDecodeError: + return await respond(send, 422, {"error": "Invalid JSON body"}) + if not isinstance(data, list): + return await respond(send, 422, {"error": "Invalid JSON body"}) + if len(data) == 0: + return await respond(send, 400, {"error": "numbers must be non-empty list"}) + nums: list[float] = [] + for v in data: + if isinstance(v, (int, float)): + nums.append(float(v)) + else: + return await respond(send, 422, {"error": "All items must be numbers"}) + return await respond(send, 200, {"result": sum(nums) / len(nums)}) + + numbers_param = query.get("numbers", [None])[0] + if numbers_param is None: + return await respond(send, 422, {"error": "Invalid JSON body"}) + parts = [p.strip() for p in numbers_param.split(",") if p.strip()] + if not parts: + return await respond(send, 400, {"error": "numbers must be non-empty list"}) + try: + nums = [float(p) for p in parts] + except ValueError: + return await respond(send, 422, {"error": "All items must be numbers"}) + return await respond(send, 200, {"result": sum(nums) / len(nums)}) + +async def handle_lifespan(receive, send): + while True: + msg = await receive() + t = msg.get("type") + if t == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif t == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + async def application( scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], @@ -12,7 +111,23 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if scope["type"] == "lifespan": + return await handle_lifespan(receive, send) + if scope["type"] != "http": + return + + method = scope["method"] + path = scope["path"] + query = parse_query(scope) + + if path.startswith("/fibonacci/"): + return await handle_fibonacci(method, path, send) + if path == "/factorial": + return await handle_factorial(method, query, send) + if path == "/mean": + return await handle_mean(method, query, receive, send) + + return await respond(send, 404, {"error": "Not found"}) if __name__ == "__main__": import uvicorn From 95aab793b0b97979712dba9a75ad629fc201b4de Mon Sep 17 00:00:00 2001 From: Dmitry Redko Date: Sun, 5 Oct 2025 12:56:13 +0300 Subject: [PATCH 2/5] HW2 done + WS --- hw2/hw/shop_api/main.py | 223 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 222 insertions(+), 1 deletion(-) diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..a64b3edf 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,224 @@ -from fastapi import FastAPI +from __future__ import annotations + +from fastapi import FastAPI, HTTPException, Path, Query, Response, WebSocket, WebSocketDisconnect +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field, model_validator +from typing import Dict, List, Optional +from http import HTTPStatus +import itertools, asyncio +from uuid import uuid4 app = FastAPI(title="Shop API") + +class ItemModel(BaseModel): + id: int + name: str + price: float + deleted: bool = False + +class ItemCreate(BaseModel): + name: str + price: float = Field(..., ge=0) + +class ItemPut(BaseModel): + name: str + price: float = Field(..., ge=0) + +class ItemPatch(BaseModel): + name: Optional[str] = None + price: Optional[float] = Field(default=None, ge=0) + + @model_validator(mode="before") + def forbid_deleted_and_unknown(cls, data): + if not isinstance(data, dict): + return data + if "deleted" in data: + raise ValueError("Field 'deleted' cannot be patched") + allowed = {"name", "price"} + unknown = set(data.keys()) - allowed + if unknown: + raise ValueError(f"Unknown fields in PATCH: {unknown}") + return data + +class CartItemView(BaseModel): + id: int + name: str + quantity: int + available: bool + +class CartView(BaseModel): + id: int + items: List[CartItemView] + price: float + +_items: Dict[int, ItemModel] = {} +_item_id_counter = itertools.count(1) +_carts: Dict[int, Dict[int, int]] = {} +_cart_id_counter = itertools.count(1) + +def _get_item(item_id: int) -> ItemModel: + item = _items.get(item_id) + if item is None or item.deleted: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + return item + +def _build_cart_view(cart_id: int) -> CartView: + if cart_id not in _carts: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Cart not found") + items_view: List[CartItemView] = [] + total_price = 0.0 + for iid, qty in _carts[cart_id].items(): + stored = _items.get(iid) + available = bool(stored and not stored.deleted) + name = stored.name if stored else f"item#{iid}" + items_view.append(CartItemView(id=iid, name=name, quantity=qty, available=available)) + if available and stored is not None: + total_price += stored.price * qty + return CartView(id=cart_id, items=items_view, price=float(total_price)) + +def _cart_total_quantity(cart_id: int) -> int: + return sum(_carts.get(cart_id, {}).values()) + +@app.post("/item", status_code=HTTPStatus.CREATED) +def create_item(payload: ItemCreate, response: Response): + new_id = next(_item_id_counter) + item = ItemModel(id=new_id, name=payload.name, price=float(payload.price), deleted=False) + _items[new_id] = item + response.headers["location"] = f"/item/{new_id}" + return item.model_dump() + +@app.get("/item/{item_id}") +def get_item(item_id: int = Path(..., ge=1)): + item = _items.get(item_id) + if item is None or item.deleted: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + return item.model_dump() + +@app.get("/item") +def list_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 = Query(False)): + items = list(_items.values()) + if not show_deleted: + items = [i for i in items if not i.deleted] + if min_price is not None: + items = [i for i in items if i.price >= min_price] + if max_price is not None: + items = [i for i in items if i.price <= max_price] + items = items[offset : offset + limit] + return [i.model_dump() for i in items] + +@app.put("/item/{item_id}") +def put_item(item_id: int = Path(..., ge=1), payload: ItemPut = ...): + existing = _items.get(item_id) + if existing is None or existing.deleted: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + existing.name = payload.name + existing.price = float(payload.price) + _items[item_id] = existing + return existing.model_dump() + +@app.patch("/item/{item_id}") +def patch_item(item_id: int = Path(..., ge=1), payload: ItemPatch = ...): + existing = _items.get(item_id) + if existing is None: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + if existing.deleted: + return JSONResponse(status_code=HTTPStatus.NOT_MODIFIED, content=None) + updated = existing.model_copy(deep=True) + if payload.name is not None: + updated.name = payload.name + if payload.price is not None: + updated.price = float(payload.price) + _items[item_id] = updated + return updated.model_dump() + +@app.delete("/item/{item_id}") +def delete_item(item_id: int = Path(..., ge=1)): + existing = _items.get(item_id) + if existing is None: + return {"status": "ok"} + if not existing.deleted: + existing.deleted = True + _items[item_id] = existing + return {"status": "ok"} + +@app.post("/cart", status_code=HTTPStatus.CREATED) +def create_cart(response: Response): + new_id = next(_cart_id_counter) + _carts[new_id] = {} + response.headers["location"] = f"/cart/{new_id}" + return {"id": new_id} + +@app.get("/cart/{cart_id}") +def get_cart(cart_id: int = Path(..., ge=1)): + view = _build_cart_view(cart_id) + return view.model_dump() + +@app.get("/cart") +def list_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)): + views = [_build_cart_view(cid) for cid in _carts.keys()] + if min_price is not None: + views = [v for v in views if v.price >= min_price] + if max_price is not None: + views = [v for v in views if v.price <= max_price] + def qty(v: CartView) -> int: + return sum(item.quantity for item in v.items) + if min_quantity is not None: + views = [v for v in views if qty(v) >= min_quantity] + if max_quantity is not None: + views = [v for v in views if qty(v) <= max_quantity] + views = views[offset : offset + limit] + return [v.model_dump() for v in views] + +@app.post("/cart/{cart_id}/add/{item_id}") +def cart_add_item(cart_id: int = Path(..., ge=1), item_id: int = Path(..., ge=1)): + if cart_id not in _carts: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Cart not found") + item = _items.get(item_id) + if item is None or item.deleted: + raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + cart = _carts[cart_id] + cart[item_id] = cart.get(item_id, 0) + 1 + _carts[cart_id] = cart + return _build_cart_view(cart_id).model_dump() + +_rooms: dict[str, set[WebSocket]] = {} +_usernames: dict[WebSocket, str] = {} +_rooms_lock = asyncio.Lock() + +def _make_username() -> str: + return f"user-{uuid4().hex[:6]}" + +async def _join_room(room: str, ws: WebSocket): + async with _rooms_lock: + _rooms.setdefault(room, set()).add(ws) + +async def _leave_room(room: str, ws: WebSocket): + async with _rooms_lock: + peers = _rooms.get(room) + if peers is not None: + peers.discard(ws) + if not peers: + _rooms.pop(room, None) + _usernames.pop(ws, None) + +@app.websocket("/chat/{chat_name}") +async def chat_ws(websocket: WebSocket, chat_name: str): + await websocket.accept() + username = _make_username() + _usernames[websocket] = username + await _join_room(chat_name, websocket) + try: + while True: + text = await websocket.receive_text() + peers = _rooms.get(chat_name, set()).copy() + for peer in peers: + if peer is websocket: + continue + try: + await peer.send_text(f"{username} :: {text}") + except RuntimeError: + pass + except WebSocketDisconnect: + pass + finally: + await _leave_room(chat_name, websocket) \ No newline at end of file From 994b246bf41b3f869929320f000e3e780555db11 Mon Sep 17 00:00:00 2001 From: Dmitry Redko Date: Sun, 12 Oct 2025 22:47:37 +0300 Subject: [PATCH 3/5] HW3 done --- hw2/hw/Dockerfile | 23 ++++++++++++ hw2/hw/docker-compose.yml | 44 +++++++++++++++++++++++ hw2/hw/requirements.txt | 2 ++ hw2/hw/settings/prometheus/prometheus.yml | 9 +++++ hw2/hw/shop_api/main.py | 2 ++ 5 files changed, 80 insertions(+) create mode 100644 hw2/hw/Dockerfile create mode 100644 hw2/hw/docker-compose.yml create mode 100644 hw2/hw/settings/prometheus/prometheus.yml diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..99cd89da --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.13 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/src +COPY . ./ + +ENV VIRTUAL_ENV=/app/src/.venv \ + PATH=/app/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"] diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..05d869c4 --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,44 @@ +version: "3" + +services: + + shop-api: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + networks: + - monitoring + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + networks: + - monitoring + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + - GF_SECURITY_ADMIN_USER=admin + + 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 + networks: + - monitoring + +networks: + monitoring: + driver: bridge diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..829949c5 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -2,6 +2,8 @@ fastapi>=0.117.1 uvicorn>=0.24.0 +prometheus-fastapi-instrumentator>=6.1.0 + # Зависимости для тестирования pytest>=7.4.0 pytest-asyncio>=0.21.0 diff --git a/hw2/hw/settings/prometheus/prometheus.yml b/hw2/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..ad3307ba --- /dev/null +++ b/hw2/hw/settings/prometheus/prometheus.yml @@ -0,0 +1,9 @@ +global: + scrape_interval: 10s + +scrape_configs: + - job_name: 'shop-api' + metrics_path: /metrics + static_configs: + - targets: + - shop-api:8080 diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index a64b3edf..9664f3b8 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -7,8 +7,10 @@ from http import HTTPStatus import itertools, asyncio from uuid import uuid4 +from prometheus_fastapi_instrumentator import Instrumentator app = FastAPI(title="Shop API") +Instrumentator().instrument(app).expose(app) class ItemModel(BaseModel): id: int From be0a3700edbb45813b99577f9cdbae2c0c094a6e Mon Sep 17 00:00:00 2001 From: Dmitry Redko Date: Sat, 25 Oct 2025 17:49:27 +0300 Subject: [PATCH 4/5] HW4_done --- hw2/hw/conftest.py | 27 ++ hw2/hw/docker-compose.yml | 21 ++ hw2/hw/requirements.txt | 3 + hw2/hw/run_demos.sh | 12 + hw2/hw/shop_api/database.py | 47 ++++ hw2/hw/shop_api/main.py | 252 ++++++++++++------ hw2/hw/transaction_demos/1_dirty_read.py | 53 ++++ .../2_non_repeatable_read.py | 58 ++++ hw2/hw/transaction_demos/3_phantom_read.py | 67 +++++ 9 files changed, 465 insertions(+), 75 deletions(-) create mode 100644 hw2/hw/conftest.py create mode 100755 hw2/hw/run_demos.sh create mode 100644 hw2/hw/shop_api/database.py create mode 100644 hw2/hw/transaction_demos/1_dirty_read.py create mode 100644 hw2/hw/transaction_demos/2_non_repeatable_read.py create mode 100644 hw2/hw/transaction_demos/3_phantom_read.py diff --git a/hw2/hw/conftest.py b/hw2/hw/conftest.py new file mode 100644 index 00000000..a45095ad --- /dev/null +++ b/hw2/hw/conftest.py @@ -0,0 +1,27 @@ +import pytest +import asyncio +from sqlalchemy import text +from shop_api.database import Base, engine, async_session_maker + + +@pytest.fixture(scope="session", autouse=True) +def setup_database(): + async def init_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + await conn.run_sync(Base.metadata.create_all) + + async with async_session_maker() as session: + await session.execute(text("DELETE FROM cart_items")) + await session.execute(text("DELETE FROM carts")) + await session.execute(text("DELETE FROM items")) + await session.commit() + + asyncio.run(init_db()) + yield + + async def cleanup_db(): + await engine.dispose() + + asyncio.run(cleanup_db()) + diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml index 05d869c4..7bb87bc7 100644 --- a/hw2/hw/docker-compose.yml +++ b/hw2/hw/docker-compose.yml @@ -2,6 +2,20 @@ version: "3" services: + postgres: + image: postgres:15-alpine + restart: always + 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 + shop-api: build: context: . @@ -10,6 +24,10 @@ services: restart: always ports: - 8080:8080 + environment: + DATABASE_URL: postgresql+asyncpg://shop_user:shop_password@postgres:5432/shop_db + depends_on: + - postgres networks: - monitoring @@ -42,3 +60,6 @@ services: networks: monitoring: driver: bridge + +volumes: + postgres_data: diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 829949c5..932b2e1d 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -4,6 +4,9 @@ uvicorn>=0.24.0 prometheus-fastapi-instrumentator>=6.1.0 +sqlalchemy>=2.0.0 +asyncpg>=0.29.0 + # Зависимости для тестирования pytest>=7.4.0 pytest-asyncio>=0.21.0 diff --git a/hw2/hw/run_demos.sh b/hw2/hw/run_demos.sh new file mode 100755 index 00000000..27fcafe8 --- /dev/null +++ b/hw2/hw/run_demos.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +cd "$(dirname "$0")" + +PYTHON=${PYTHON:-python3} + +for script in transaction_demos/[0-9]*.py; do + echo "=== $script ===" + $PYTHON "$script" + echo "" +done + diff --git a/hw2/hw/shop_api/database.py b/hw2/hw/shop_api/database.py new file mode 100644 index 00000000..5ea6cfc7 --- /dev/null +++ b/hw2/hw/shop_api/database.py @@ -0,0 +1,47 @@ +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy import String, Float, Boolean, Integer, ForeignKey +from sqlalchemy.pool import NullPool +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://shop_user:shop_password@localhost:5432/shop_db") + +engine = create_async_engine( + DATABASE_URL, + echo=False, + poolclass=NullPool +) +async_session_maker = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + +class Base(DeclarativeBase): + pass + +class Item(Base): + __tablename__ = "items" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String, nullable=False) + price: Mapped[float] = mapped_column(Float, nullable=False) + deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + +class Cart(Base): + __tablename__ = "carts" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + +class CartItem(Base): + __tablename__ = "cart_items" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + cart_id: Mapped[int] = mapped_column(Integer, ForeignKey("carts.id"), nullable=False) + item_id: Mapped[int] = mapped_column(Integer, ForeignKey("items.id"), nullable=False) + quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + +async def init_db(): + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + +async def get_session() -> AsyncSession: + async with async_session_maker() as session: + yield session + diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index 9664f3b8..42319f3d 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,22 +1,26 @@ from __future__ import annotations -from fastapi import FastAPI, HTTPException, Path, Query, Response, WebSocket, WebSocketDisconnect +from fastapi import FastAPI, HTTPException, Path, Query, Response, WebSocket, WebSocketDisconnect, Depends from fastapi.responses import JSONResponse from pydantic import BaseModel, Field, model_validator from typing import Dict, List, Optional from http import HTTPStatus -import itertools, asyncio +import asyncio from uuid import uuid4 from prometheus_fastapi_instrumentator import Instrumentator +from sqlalchemy import select, and_ +from sqlalchemy.ext.asyncio import AsyncSession +from contextlib import asynccontextmanager -app = FastAPI(title="Shop API") -Instrumentator().instrument(app).expose(app) +from shop_api.database import init_db, get_session, Item as ItemDB, Cart as CartDB, CartItem as CartItemDB -class ItemModel(BaseModel): - id: int - name: str - price: float - deleted: bool = False +@asynccontextmanager +async def lifespan(app: FastAPI): + await init_db() + yield + +app = FastAPI(title="Shop API", lifespan=lifespan) +Instrumentator().instrument(app).expose(app) class ItemCreate(BaseModel): name: str @@ -53,136 +57,234 @@ class CartView(BaseModel): items: List[CartItemView] price: float -_items: Dict[int, ItemModel] = {} -_item_id_counter = itertools.count(1) -_carts: Dict[int, Dict[int, int]] = {} -_cart_id_counter = itertools.count(1) - -def _get_item(item_id: int) -> ItemModel: - item = _items.get(item_id) +async def _get_item(item_id: int, session: AsyncSession) -> ItemDB: + result = await session.execute(select(ItemDB).where(ItemDB.id == item_id)) + item = result.scalar_one_or_none() if item is None or item.deleted: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") return item -def _build_cart_view(cart_id: int) -> CartView: - if cart_id not in _carts: +async def _build_cart_view(cart_id: int, session: AsyncSession) -> CartView: + # Check if cart exists + cart_result = await session.execute(select(CartDB).where(CartDB.id == cart_id)) + cart = cart_result.scalar_one_or_none() + if cart is None: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Cart not found") + + # Get cart items with their details + cart_items_result = await session.execute( + select(CartItemDB, ItemDB) + .join(ItemDB, CartItemDB.item_id == ItemDB.id, isouter=True) + .where(CartItemDB.cart_id == cart_id) + ) + cart_items = cart_items_result.all() + items_view: List[CartItemView] = [] total_price = 0.0 - for iid, qty in _carts[cart_id].items(): - stored = _items.get(iid) - available = bool(stored and not stored.deleted) - name = stored.name if stored else f"item#{iid}" - items_view.append(CartItemView(id=iid, name=name, quantity=qty, available=available)) - if available and stored is not None: - total_price += stored.price * qty + + for cart_item, item in cart_items: + available = bool(item and not item.deleted) + name = item.name if item else f"item#{cart_item.item_id}" + items_view.append(CartItemView( + id=cart_item.item_id, + name=name, + quantity=cart_item.quantity, + available=available + )) + if available and item is not None: + total_price += item.price * cart_item.quantity + return CartView(id=cart_id, items=items_view, price=float(total_price)) -def _cart_total_quantity(cart_id: int) -> int: - return sum(_carts.get(cart_id, {}).values()) - @app.post("/item", status_code=HTTPStatus.CREATED) -def create_item(payload: ItemCreate, response: Response): - new_id = next(_item_id_counter) - item = ItemModel(id=new_id, name=payload.name, price=float(payload.price), deleted=False) - _items[new_id] = item - response.headers["location"] = f"/item/{new_id}" - return item.model_dump() +async def create_item(payload: ItemCreate, response: Response, session: AsyncSession = Depends(get_session)): + item = ItemDB(name=payload.name, price=float(payload.price), deleted=False) + session.add(item) + await session.commit() + await session.refresh(item) + response.headers["location"] = f"/item/{item.id}" + return {"id": item.id, "name": item.name, "price": item.price, "deleted": item.deleted} @app.get("/item/{item_id}") -def get_item(item_id: int = Path(..., ge=1)): - item = _items.get(item_id) +async def get_item(item_id: int = Path(..., ge=1), session: AsyncSession = Depends(get_session)): + result = await session.execute(select(ItemDB).where(ItemDB.id == item_id)) + item = result.scalar_one_or_none() if item is None or item.deleted: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") - return item.model_dump() + return {"id": item.id, "name": item.name, "price": item.price, "deleted": item.deleted} @app.get("/item") -def list_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 = Query(False)): - items = list(_items.values()) +async def list_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 = Query(False), + session: AsyncSession = Depends(get_session) +): + query = select(ItemDB) + if not show_deleted: - items = [i for i in items if not i.deleted] + query = query.where(ItemDB.deleted == False) + if min_price is not None: - items = [i for i in items if i.price >= min_price] + query = query.where(ItemDB.price >= min_price) + if max_price is not None: - items = [i for i in items if i.price <= max_price] - items = items[offset : offset + limit] - return [i.model_dump() for i in items] + query = query.where(ItemDB.price <= max_price) + + query = query.offset(offset).limit(limit) + + result = await session.execute(query) + items = result.scalars().all() + + return [{"id": i.id, "name": i.name, "price": i.price, "deleted": i.deleted} for i in items] @app.put("/item/{item_id}") -def put_item(item_id: int = Path(..., ge=1), payload: ItemPut = ...): - existing = _items.get(item_id) +async def put_item( + item_id: int = Path(..., ge=1), + payload: ItemPut = ..., + session: AsyncSession = Depends(get_session) +): + result = await session.execute(select(ItemDB).where(ItemDB.id == item_id)) + existing = result.scalar_one_or_none() if existing is None or existing.deleted: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") + existing.name = payload.name existing.price = float(payload.price) - _items[item_id] = existing - return existing.model_dump() + await session.commit() + await session.refresh(existing) + + return {"id": existing.id, "name": existing.name, "price": existing.price, "deleted": existing.deleted} @app.patch("/item/{item_id}") -def patch_item(item_id: int = Path(..., ge=1), payload: ItemPatch = ...): - existing = _items.get(item_id) +async def patch_item( + item_id: int = Path(..., ge=1), + payload: ItemPatch = ..., + session: AsyncSession = Depends(get_session) +): + result = await session.execute(select(ItemDB).where(ItemDB.id == item_id)) + existing = result.scalar_one_or_none() if existing is None: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") if existing.deleted: return JSONResponse(status_code=HTTPStatus.NOT_MODIFIED, content=None) - updated = existing.model_copy(deep=True) + if payload.name is not None: - updated.name = payload.name + existing.name = payload.name if payload.price is not None: - updated.price = float(payload.price) - _items[item_id] = updated - return updated.model_dump() + existing.price = float(payload.price) + + await session.commit() + await session.refresh(existing) + + return {"id": existing.id, "name": existing.name, "price": existing.price, "deleted": existing.deleted} @app.delete("/item/{item_id}") -def delete_item(item_id: int = Path(..., ge=1)): - existing = _items.get(item_id) +async def delete_item(item_id: int = Path(..., ge=1), session: AsyncSession = Depends(get_session)): + result = await session.execute(select(ItemDB).where(ItemDB.id == item_id)) + existing = result.scalar_one_or_none() if existing is None: return {"status": "ok"} + if not existing.deleted: existing.deleted = True - _items[item_id] = existing + await session.commit() + return {"status": "ok"} @app.post("/cart", status_code=HTTPStatus.CREATED) -def create_cart(response: Response): - new_id = next(_cart_id_counter) - _carts[new_id] = {} - response.headers["location"] = f"/cart/{new_id}" - return {"id": new_id} +async def create_cart(response: Response, session: AsyncSession = Depends(get_session)): + cart = CartDB() + session.add(cart) + await session.commit() + await session.refresh(cart) + response.headers["location"] = f"/cart/{cart.id}" + return {"id": cart.id} @app.get("/cart/{cart_id}") -def get_cart(cart_id: int = Path(..., ge=1)): - view = _build_cart_view(cart_id) +async def get_cart(cart_id: int = Path(..., ge=1), session: AsyncSession = Depends(get_session)): + view = await _build_cart_view(cart_id, session) return view.model_dump() @app.get("/cart") -def list_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)): - views = [_build_cart_view(cid) for cid in _carts.keys()] +async def list_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), + session: AsyncSession = Depends(get_session) +): + # Get all carts + result = await session.execute(select(CartDB)) + carts = result.scalars().all() + + # Build views for all carts + views = [] + for cart in carts: + view = await _build_cart_view(cart.id, session) + views.append(view) + + # Apply filters if min_price is not None: views = [v for v in views if v.price >= min_price] if max_price is not None: views = [v for v in views if v.price <= max_price] + def qty(v: CartView) -> int: return sum(item.quantity for item in v.items) + if min_quantity is not None: views = [v for v in views if qty(v) >= min_quantity] if max_quantity is not None: views = [v for v in views if qty(v) <= max_quantity] + + # Apply pagination views = views[offset : offset + limit] + return [v.model_dump() for v in views] @app.post("/cart/{cart_id}/add/{item_id}") -def cart_add_item(cart_id: int = Path(..., ge=1), item_id: int = Path(..., ge=1)): - if cart_id not in _carts: +async def cart_add_item( + cart_id: int = Path(..., ge=1), + item_id: int = Path(..., ge=1), + session: AsyncSession = Depends(get_session) +): + # Check if cart exists + cart_result = await session.execute(select(CartDB).where(CartDB.id == cart_id)) + cart = cart_result.scalar_one_or_none() + if cart is None: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Cart not found") - item = _items.get(item_id) + + # Check if item exists and is not deleted + item_result = await session.execute(select(ItemDB).where(ItemDB.id == item_id)) + item = item_result.scalar_one_or_none() if item is None or item.deleted: raise HTTPException(status_code=HTTPStatus.NOT_FOUND, detail="Item not found") - cart = _carts[cart_id] - cart[item_id] = cart.get(item_id, 0) + 1 - _carts[cart_id] = cart - return _build_cart_view(cart_id).model_dump() + + # Check if item is already in cart + cart_item_result = await session.execute( + select(CartItemDB).where( + and_(CartItemDB.cart_id == cart_id, CartItemDB.item_id == item_id) + ) + ) + cart_item = cart_item_result.scalar_one_or_none() + + if cart_item: + cart_item.quantity += 1 + else: + cart_item = CartItemDB(cart_id=cart_id, item_id=item_id, quantity=1) + session.add(cart_item) + + await session.commit() + + view = await _build_cart_view(cart_id, session) + return view.model_dump() +# WebSocket chat functionality _rooms: dict[str, set[WebSocket]] = {} _usernames: dict[WebSocket, str] = {} _rooms_lock = asyncio.Lock() @@ -223,4 +325,4 @@ async def chat_ws(websocket: WebSocket, chat_name: str): except WebSocketDisconnect: pass finally: - await _leave_room(chat_name, websocket) \ No newline at end of file + await _leave_room(chat_name, websocket) diff --git a/hw2/hw/transaction_demos/1_dirty_read.py b/hw2/hw/transaction_demos/1_dirty_read.py new file mode 100644 index 00000000..ffc75ee5 --- /dev/null +++ b/hw2/hw/transaction_demos/1_dirty_read.py @@ -0,0 +1,53 @@ +import asyncio +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy import text +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://shop_user:shop_password@localhost:5432/shop_db") + +async def setup(engine): + async with engine.begin() as conn: + await conn.execute(text("DROP TABLE IF EXISTS accounts CASCADE")) + await conn.execute(text("CREATE TABLE accounts (id SERIAL PRIMARY KEY, balance INTEGER)")) + await conn.execute(text("INSERT INTO accounts (balance) VALUES (1000)")) + +async def tx1_uncommitted(engine): + async with AsyncSession(engine, expire_on_commit=False) as s: + await s.execute(text("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED")) + await s.execute(text("UPDATE accounts SET balance = 2000")) + await asyncio.sleep(1) + await s.rollback() + +async def tx2_uncommitted(engine): + await asyncio.sleep(0.5) + async with AsyncSession(engine, expire_on_commit=False) as s: + await s.execute(text("SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED")) + r = await s.execute(text("SELECT balance FROM accounts")) + print(f"READ UNCOMMITTED: {r.scalar()} (ожидаем 1000, PostgreSQL не поддерживает READ UNCOMMITTED)") + +async def tx1_committed(engine): + async with AsyncSession(engine, expire_on_commit=False) as s: + await s.execute(text("UPDATE accounts SET balance = 2000")) + await asyncio.sleep(1) + await s.rollback() + +async def tx2_committed(engine): + await asyncio.sleep(0.5) + async with AsyncSession(engine, expire_on_commit=False) as s: + r = await s.execute(text("SELECT balance FROM accounts")) + print(f"READ COMMITTED: {r.scalar()} (ожидаем 1000)") + +async def main(): + print("DIRTY READ") + engine = create_async_engine(DATABASE_URL, echo=False) + try: + await setup(engine) + await asyncio.gather(tx1_uncommitted(engine), tx2_uncommitted(engine)) + await setup(engine) + await asyncio.gather(tx1_committed(engine), tx2_committed(engine)) + finally: + await engine.dispose() + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/hw2/hw/transaction_demos/2_non_repeatable_read.py b/hw2/hw/transaction_demos/2_non_repeatable_read.py new file mode 100644 index 00000000..cb8ad47b --- /dev/null +++ b/hw2/hw/transaction_demos/2_non_repeatable_read.py @@ -0,0 +1,58 @@ +import asyncio +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy import text +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://shop_user:shop_password@localhost:5432/shop_db") + +async def setup(engine): + async with engine.begin() as conn: + await conn.execute(text("DROP TABLE IF EXISTS products CASCADE")) + await conn.execute(text("CREATE TABLE products (id SERIAL PRIMARY KEY, price INTEGER)")) + await conn.execute(text("INSERT INTO products (price) VALUES (100)")) + +async def tx1_read_committed(engine): + async with AsyncSession(engine, expire_on_commit=False) as s: + r1 = await s.execute(text("SELECT price FROM products")) + first = r1.scalar() + await asyncio.sleep(1) + r2 = await s.execute(text("SELECT price FROM products")) + second = r2.scalar() + print(f"READ COMMITTED: первое={first}, второе={second} (разные значения)") + +async def tx2_read_committed(engine): + await asyncio.sleep(0.5) + async with AsyncSession(engine, expire_on_commit=False) as s: + await s.execute(text("UPDATE products SET price = 200")) + await s.commit() + +async def tx1_repeatable_read(engine): + async with AsyncSession(engine, expire_on_commit=False) as s: + await s.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + r1 = await s.execute(text("SELECT price FROM products")) + first = r1.scalar() + await asyncio.sleep(1) + r2 = await s.execute(text("SELECT price FROM products")) + second = r2.scalar() + print(f"REPEATABLE READ: первое={first}, второе={second} (одинаковые)") + +async def tx2_repeatable_read(engine): + await asyncio.sleep(0.5) + async with AsyncSession(engine, expire_on_commit=False) as s: + await s.execute(text("UPDATE products SET price = 200")) + await s.commit() + +async def main(): + print("NON-REPEATABLE READ") + engine = create_async_engine(DATABASE_URL, echo=False) + try: + await setup(engine) + await asyncio.gather(tx1_read_committed(engine), tx2_read_committed(engine)) + await setup(engine) + await asyncio.gather(tx1_repeatable_read(engine), tx2_repeatable_read(engine)) + finally: + await engine.dispose() + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/hw2/hw/transaction_demos/3_phantom_read.py b/hw2/hw/transaction_demos/3_phantom_read.py new file mode 100644 index 00000000..f7dfa4c5 --- /dev/null +++ b/hw2/hw/transaction_demos/3_phantom_read.py @@ -0,0 +1,67 @@ +import asyncio +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy import text +import os + +DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://shop_user:shop_password@localhost:5432/shop_db") + +async def setup(engine): + async with engine.begin() as conn: + await conn.execute(text("DROP TABLE IF EXISTS orders CASCADE")) + await conn.execute(text("CREATE TABLE orders (id SERIAL PRIMARY KEY, amount INTEGER)")) + await conn.execute(text("INSERT INTO orders (amount) VALUES (100), (200)")) + +async def tx1_repeatable_read(engine): + async with AsyncSession(engine, expire_on_commit=False) as s: + await s.execute(text("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ")) + r1 = await s.execute(text("SELECT COUNT(*) FROM orders")) + first = r1.scalar() + await asyncio.sleep(1) + r2 = await s.execute(text("SELECT COUNT(*) FROM orders")) + second = r2.scalar() + print(f"REPEATABLE READ: первое={first}, второе={second} (одинаковые, PostgreSQL предотвращает phantom)") + +async def tx2_repeatable_read(engine): + await asyncio.sleep(0.5) + async with AsyncSession(engine, expire_on_commit=False) as s: + await s.execute(text("INSERT INTO orders (amount) VALUES (300)")) + await s.commit() + +async def tx1_serializable(engine): + try: + async with AsyncSession(engine, expire_on_commit=False) as s: + await s.execute(text("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")) + r1 = await s.execute(text("SELECT COUNT(*) FROM orders")) + first = r1.scalar() + await asyncio.sleep(1) + r2 = await s.execute(text("SELECT COUNT(*) FROM orders")) + second = r2.scalar() + await s.commit() + print(f"SERIALIZABLE: первое={first}, второе={second} (одинаковые)") + except Exception as e: + print(f"SERIALIZABLE: ошибка сериализации (ожидаемо)") + +async def tx2_serializable(engine): + await asyncio.sleep(0.5) + try: + async with AsyncSession(engine, expire_on_commit=False) as s: + await s.execute(text("SET TRANSACTION ISOLATION LEVEL SERIALIZABLE")) + await s.execute(text("INSERT INTO orders (amount) VALUES (300)")) + await s.commit() + except Exception: + pass + +async def main(): + print("PHANTOM READ") + engine = create_async_engine(DATABASE_URL, echo=False) + try: + await setup(engine) + await asyncio.gather(tx1_repeatable_read(engine), tx2_repeatable_read(engine)) + await setup(engine) + await asyncio.gather(tx1_serializable(engine), tx2_serializable(engine)) + finally: + await engine.dispose() + +if __name__ == "__main__": + asyncio.run(main()) + From c5cefb84921e7ff6c4e48a01b5010ca5df3beb1a Mon Sep 17 00:00:00 2001 From: Dmitry Redko Date: Sat, 25 Oct 2025 20:40:07 +0300 Subject: [PATCH 5/5] HW5_done --- .github/workflows/hw2-tests.yml | 71 +++++++++----- hw2/hw/.coveragerc | 6 ++ hw2/hw/__init__.py | 0 hw2/hw/conftest.py | 6 -- hw2/hw/requirements.txt | 1 + hw2/hw/shop_api/__init__.py | 0 hw2/hw/shop_api/main.py | 2 +- hw2/hw/test_additional.py | 141 +++++++++++++++++++++++++++ hw2/hw/transaction_demos/__init__.py | 1 + 9 files changed, 197 insertions(+), 31 deletions(-) create mode 100644 hw2/hw/.coveragerc delete mode 100644 hw2/hw/__init__.py delete mode 100644 hw2/hw/shop_api/__init__.py create mode 100644 hw2/hw/test_additional.py create mode 100644 hw2/hw/transaction_demos/__init__.py diff --git a/.github/workflows/hw2-tests.yml b/.github/workflows/hw2-tests.yml index be7fc297..d14b3c55 100644 --- a/.github/workflows/hw2-tests.yml +++ b/.github/workflows/hw2-tests.yml @@ -1,39 +1,62 @@ -name: "HW2 Tests" +name: HW2 Tests -# Запускаем тесты при изменении файлов в hw2/hw/ on: - pull_request: - branches: [ main ] - paths: [ 'hw2/hw/**' ] push: - branches: [ main ] - paths: [ 'hw2/hw/**' ] + branches: [ main, master ] + paths: + - 'hw2/**' + - '.github/workflows/hw2-tests.yml' + pull_request: + branches: [ main, master ] + paths: + - 'hw2/**' + - '.github/workflows/hw2-tests.yml' jobs: - test-hw2: + test: runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.12", "3.13"] - + + services: + postgres: + image: postgres:15-alpine + env: + POSTGRES_DB: shop_db + POSTGRES_USER: shop_user + POSTGRES_PASSWORD: shop_password + 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 }} - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} - + python-version: '3.11' + - name: Install dependencies - working-directory: hw2/hw run: | python -m pip install --upgrade pip - pip install -r requirements.txt + pip install -r hw2/hw/requirements.txt + pip install pytest-cov - - name: Run tests - working-directory: hw2/hw + - name: Run tests with coverage env: + DATABASE_URL: postgresql+asyncpg://shop_user:shop_password@localhost:5432/shop_db PYTHONPATH: ${{ github.workspace }}/hw2/hw run: | - pytest test_homework2.py -v + cd hw2/hw + pytest test_homework2.py test_additional.py --cov=shop_api --cov-report=term --cov-report=xml --cov-fail-under=95 -v + + - name: Upload coverage reports + uses: codecov/codecov-action@v4 + if: always() + with: + file: ./hw2/hw/coverage.xml + flags: hw2 + name: hw2-coverage diff --git a/hw2/hw/.coveragerc b/hw2/hw/.coveragerc new file mode 100644 index 00000000..1324ca0c --- /dev/null +++ b/hw2/hw/.coveragerc @@ -0,0 +1,6 @@ +[run] +source = shop_api +concurrency = thread,greenlet + +[report] +precision = 2 diff --git a/hw2/hw/__init__.py b/hw2/hw/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/hw2/hw/conftest.py b/hw2/hw/conftest.py index a45095ad..436d207e 100644 --- a/hw2/hw/conftest.py +++ b/hw2/hw/conftest.py @@ -10,12 +10,6 @@ async def init_db(): async with engine.begin() as conn: await conn.run_sync(Base.metadata.drop_all) await conn.run_sync(Base.metadata.create_all) - - async with async_session_maker() as session: - await session.execute(text("DELETE FROM cart_items")) - await session.execute(text("DELETE FROM carts")) - await session.execute(text("DELETE FROM items")) - await session.commit() asyncio.run(init_db()) yield diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 932b2e1d..f72bb659 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -10,5 +10,6 @@ asyncpg>=0.29.0 # Зависимости для тестирования pytest>=7.4.0 pytest-asyncio>=0.21.0 +pytest-cov>=4.1.0 httpx>=0.27.2 Faker>=37.8.0 diff --git a/hw2/hw/shop_api/__init__.py b/hw2/hw/shop_api/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index 42319f3d..a2019d78 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -17,7 +17,7 @@ @asynccontextmanager async def lifespan(app: FastAPI): await init_db() - yield + yield # pragma: no cover app = FastAPI(title="Shop API", lifespan=lifespan) Instrumentator().instrument(app).expose(app) diff --git a/hw2/hw/test_additional.py b/hw2/hw/test_additional.py new file mode 100644 index 00000000..a2a1930f --- /dev/null +++ b/hw2/hw/test_additional.py @@ -0,0 +1,141 @@ +import pytest +from fastapi.testclient import TestClient +from shop_api.main import app, _make_username, _get_item, _build_cart_view +from shop_api.database import get_session +from fastapi import HTTPException + +client = TestClient(app) + + +def test_websocket_basic(): + with client.websocket_connect("/chat/room") as ws1: + with client.websocket_connect("/chat/room") as ws2: + ws1.send_text("hello") + assert "hello" in ws2.receive_text() + + +def test_cart_add_increment_quantity(): + item = client.post("/item", json={"name": "T", "price": 100}).json() + cart = client.post("/cart").json() + client.post(f"/cart/{cart['id']}/add/{item['id']}") + response = client.post(f"/cart/{cart['id']}/add/{item['id']}") + assert response.json()['items'][0]['quantity'] == 2 + + +def test_item_patch_separate_fields(): + item = client.post("/item", json={"name": "Old", "price": 50}).json() + assert client.patch(f"/item/{item['id']}", json={"name": "New"}).json()['name'] == "New" + + item2 = client.post("/item", json={"name": "Test", "price": 50}).json() + assert client.patch(f"/item/{item2['id']}", json={"price": 100}).json()['price'] == 100 + + +def test_item_patch_validations(): + item = client.post("/item", json={"name": "T", "price": 50}).json() + assert client.patch(f"/item/{item['id']}", json={"deleted": True}).status_code == 422 + assert client.patch(f"/item/{item['id']}", json={"unknown": "x"}).status_code == 422 + + +def test_item_patch_deleted(): + item = client.post("/item", json={"name": "T", "price": 50}).json() + client.delete(f"/item/{item['id']}") + assert client.patch(f"/item/{item['id']}", json={"name": "N"}).status_code == 304 + + +def test_delete_operations(): + item = client.post("/item", json={"name": "T", "price": 50}).json() + assert client.delete(f"/item/{item['id']}").status_code == 200 + assert client.delete(f"/item/{item['id']}").status_code == 200 + assert client.delete("/item/999999").status_code == 200 + + +def test_cart_with_deleted_item(): + item = client.post("/item", json={"name": "T", "price": 100}).json() + cart = client.post("/cart").json() + client.post(f"/cart/{cart['id']}/add/{item['id']}") + client.delete(f"/item/{item['id']}") + data = client.get(f"/cart/{cart['id']}").json() + assert data['items'][0]['available'] == False + assert data['price'] == 0 + + +def test_item_filters(): + client.post("/item", json={"name": "Cheap", "price": 10}) + client.post("/item", json={"name": "Expensive", "price": 500}) + items = client.get("/item?min_price=100&max_price=600&limit=100").json() + assert all(100 <= i['price'] <= 600 for i in items) + + +def test_item_show_deleted(): + item = client.post("/item", json={"name": "D", "price": 50}).json() + client.delete(f"/item/{item['id']}") + items = client.get("/item?show_deleted=true&limit=100").json() + deleted = [i for i in items if i['id'] == item['id']] + assert len(deleted) > 0 and deleted[0]['deleted'] + + +def test_cart_filters(): + item = client.post("/item", json={"name": "A", "price": 200}).json() + cart = client.post("/cart").json() + for _ in range(3): + client.post(f"/cart/{cart['id']}/add/{item['id']}") + + carts = client.get("/cart?min_price=100&max_price=1000&min_quantity=2&max_quantity=5&limit=100").json() + assert any(c['id'] == cart['id'] for c in carts) + + +def test_pagination(): + for i in range(15): + client.post("/item", json={"name": f"I{i}", "price": i}) + assert len(client.get("/item?offset=10&limit=5").json()) <= 5 + + for _ in range(12): + client.post("/cart") + assert len(client.get("/cart?offset=5&limit=5").json()) <= 5 + + +def test_404_scenarios(): + assert client.get("/item/999999").status_code == 404 + assert client.get("/cart/999999").status_code == 404 + assert client.put("/item/999999", json={"name": "X", "price": 1}).status_code == 404 + assert client.patch("/item/999999", json={"name": "X"}).status_code == 404 + + item = client.post("/item", json={"name": "T", "price": 1}).json() + cart = client.post("/cart").json() + assert client.post(f"/cart/999999/add/{item['id']}").status_code == 404 + assert client.post(f"/cart/{cart['id']}/add/999999").status_code == 404 + + client.delete(f"/item/{item['id']}") + assert client.post(f"/cart/{cart['id']}/add/{item['id']}").status_code == 404 + assert client.put(f"/item/{item['id']}", json={"name": "N", "price": 10}).status_code == 404 + + +def test_make_username(): + u1, u2 = _make_username(), _make_username() + assert u1.startswith("user-") and u1 != u2 + + +@pytest.mark.asyncio +async def test_async_functions(): + item = client.post("/item", json={"name": "T", "price": 10}).json() + cart = client.post("/cart").json() + client.post(f"/cart/{cart['id']}/add/{item['id']}") + client.delete(f"/item/{item['id']}") + + async for session in get_session(): + with pytest.raises(HTTPException): + await _get_item(item['id'], session) + with pytest.raises(HTTPException): + await _get_item(999999, session) + + view = await _build_cart_view(cart['id'], session) + assert view.price == 0 + + with pytest.raises(HTTPException): + await _build_cart_view(999999, session) + break + + +def test_pydantic_validator(): + from shop_api.main import ItemPatch + assert ItemPatch.forbid_deleted_and_unknown("not_dict") == "not_dict" diff --git a/hw2/hw/transaction_demos/__init__.py b/hw2/hw/transaction_demos/__init__.py new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/hw2/hw/transaction_demos/__init__.py @@ -0,0 +1 @@ +