diff --git a/.github/workflows/hw2-tests.yml b/.github/workflows/hw2-tests.yml index be7fc297..f233df18 100644 --- a/.github/workflows/hw2-tests.yml +++ b/.github/workflows/hw2-tests.yml @@ -12,6 +12,22 @@ on: jobs: test-hw2: runs-on: ubuntu-latest + + 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 + strategy: matrix: python-version: ["3.12", "3.13"] @@ -34,6 +50,7 @@ jobs: - name: Run tests working-directory: hw2/hw env: + DATABASE_URL: postgresql+psycopg2://shop_user:shop_password@localhost:5432/shop_db PYTHONPATH: ${{ github.workspace }}/hw2/hw run: | - pytest test_homework2.py -v + pytest test_homework2.py test_additional_api.py --cov=shop_api --cov-report=term --cov-fail-under=95 -v diff --git a/.gitignore b/.gitignore index 852216e6..8e2cbd32 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,6 @@ dmypy.json # macOS .DS_Store + +# data +pgdata/ \ No newline at end of file diff --git a/hw1/app.py b/hw1/app.py index 6107b870..a4d71f08 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,6 +1,60 @@ from typing import Any, Awaitable, Callable +import json +from urllib.parse import parse_qs +import math +def parse_int(value: str) -> int | None: + try: + if value is None or value == "": + return None + return int(value) + except (TypeError, ValueError): + return None + +def fibonacci(n: int) -> int: + if n == 0: + return 0 + if n == 1: + return 1 + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + return b + +async def send_json( + send: Callable[[dict[str, Any]], Awaitable[None]], + status: int, + payload: dict[str, Any] | list[Any] | None, +) -> None: + body_bytes = json.dumps(payload if payload is not None else {}).encode("utf-8") + await send( + { + "type": "http.response.start", + "status": status, + "headers": [ + (b"content-type", b"application/json; charset=utf-8"), + (b"content-length", str(len(body_bytes)).encode("ascii")), + ], + } + ) + await send({"type": "http.response.body", "body": body_bytes}) + +async def read_body_bytes( + receive: Callable[[], Awaitable[dict[str, Any]]] +) -> bytes: + chunks: list[bytes] = [] + more = True + while more: + message = await receive() + if message.get("type") != "http.request": + break + body = message.get("body", b"") or b"" + if body: + chunks.append(body) + more = bool(message.get("more_body")) + return b"".join(chunks) + async def application( scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], @@ -12,7 +66,87 @@ async def application( receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту """ - # TODO: Ваша реализация здесь + if scope.get("type") != "http": + await send( + { + "type": "http.response.start", + "status": 404, + "headers": [(b"content-type", b"application/json; charset=utf-8")], + } + ) + await send({"type": "http.response.body", "body": b"{}"}) + return + + method: str = scope.get("method", "GET").upper() + raw_path: bytes = scope.get("raw_path") or scope.get("path", "/").encode() + path: str = (raw_path.decode("utf-8") if isinstance(raw_path, (bytes, bytearray)) else str(raw_path)) + + if method != "GET": + await send_json(send, 404, {}) + return + + if path == "/factorial": + query_bytes: bytes = scope.get("query_string", b"") or b"" + qs = parse_qs(query_bytes.decode("utf-8"), keep_blank_values=True) + n_values = qs.get("n") + if not n_values: + await send_json(send, 422, {}) + return + n_raw = n_values[0] + n = parse_int(n_raw) + if n is None: + await send_json(send, 422, {}) + return + if n < 0: + await send_json(send, 400, {}) + return + await send_json(send, 200, {"result": math.factorial(n)}) + return + + if path.startswith("/fibonacci"): + parts = path.split("/") + if len(parts) != 3 or parts[2] == "": + await send_json(send, 422, {}) + return + n = parse_int(parts[2]) + if n is None: + await send_json(send, 422, {}) + return + if n < 0: + await send_json(send, 400, {}) + return + await send_json(send, 200, {"result": fibonacci(n)}) + return + + if path == "/mean": + body = await read_body_bytes(receive) + if not body: + await send_json(send, 422, {}) + return + try: + data = json.loads(body.decode("utf-8")) + except Exception: + await send_json(send, 422, {}) + return + if not isinstance(data, list): + await send_json(send, 422, {}) + return + if len(data) == 0: + await send_json(send, 400, {}) + return + total = 0.0 + count = 0 + for item in data: + if not isinstance(item, (int, float)): + await send_json(send, 422, {}) + return + total += float(item) + count += 1 + result = total / count if count > 0 else 0.0 + await send_json(send, 200, {"result": result}) + return + + await send_json(send, 404, {}) if __name__ == "__main__": import uvicorn diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..a334c616 --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,24 @@ +FROM python:3.12 AS base + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR /app/src +COPY ./requirements.txt ./ +COPY ./shop_api ./shop_api + +# 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"] \ 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..ecd5382f --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,49 @@ +version: "3" + +services: + + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + environment: + - DATABASE_URL=postgresql+psycopg2://shop_user:shop_password@postgres:5432/shop_db + depends_on: + - postgres + - prometheus + - grafana + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + + 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 + + postgres: + image: postgres:15-alpine + environment: + - POSTGRES_DB=shop_db + - POSTGRES_USER=shop_user + - POSTGRES_PASSWORD=shop_password + ports: + - 5432:5432 + volumes: + - ./pgdata:/var/lib/postgresql/data + restart: always diff --git a/hw2/hw/grafana_example.png b/hw2/hw/grafana_example.png new file mode 100644 index 00000000..22db7161 Binary files /dev/null and b/hw2/hw/grafana_example.png differ diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..5b0f7f48 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,9 +1,15 @@ # Основные зависимости для ASGI приложения fastapi>=0.117.1 uvicorn>=0.24.0 +prometheus-fastapi-instrumentator==7.1.0 # Зависимости для тестирования pytest>=7.4.0 pytest-asyncio>=0.21.0 httpx>=0.27.2 Faker>=37.8.0 +pytest-cov>=5.0.0 + +# Database +SQLAlchemy>=2.0.36 +psycopg2-binary>=2.9.10 diff --git a/hw2/hw/settings/prometheus/prometheus.yml b/hw2/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..74788ec3 --- /dev/null +++ b/hw2/hw/settings/prometheus/prometheus.yml @@ -0,0 +1,10 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-api-local + metrics_path: /metrics + static_configs: + - targets: + - local:8080 \ No newline at end of file diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..d6d4060b 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,20 @@ from fastapi import FastAPI +from contextlib import asynccontextmanager +from prometheus_fastapi_instrumentator import Instrumentator + +from .routes import items_router, carts_router +from .storage import storage + + +@asynccontextmanager +async def lifespan(app: FastAPI): + _ = storage + yield + + +app = FastAPI(title="Shop API", lifespan=lifespan) +Instrumentator().instrument(app).expose(app) + +app.include_router(items_router) +app.include_router(carts_router) -app = FastAPI(title="Shop API") diff --git a/hw2/hw/shop_api/models.py b/hw2/hw/shop_api/models.py new file mode 100644 index 00000000..649e65b7 --- /dev/null +++ b/hw2/hw/shop_api/models.py @@ -0,0 +1,46 @@ +from typing import Optional +from pydantic import BaseModel, Field, field_validator, ConfigDict + + +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) + + @field_validator('price') + @classmethod + def validate_price(cls, v): + if v is not None and v <= 0: + raise ValueError('Price must be positive') + return v + + +class ItemResponse(BaseModel): + id: int + name: str + price: float + deleted: bool = False + + +class CartItemResponse(BaseModel): + id: int + name: str + quantity: int + available: bool + + +class CartResponse(BaseModel): + id: int + items: list[CartItemResponse] + price: float diff --git a/hw2/hw/shop_api/routes/__init__.py b/hw2/hw/shop_api/routes/__init__.py new file mode 100644 index 00000000..122d4b5d --- /dev/null +++ b/hw2/hw/shop_api/routes/__init__.py @@ -0,0 +1,4 @@ +from .items import router as items_router +from .carts import router as carts_router + +__all__ = ["items_router", "carts_router"] \ No newline at end of file diff --git a/hw2/hw/shop_api/routes/carts.py b/hw2/hw/shop_api/routes/carts.py new file mode 100644 index 00000000..812824a4 --- /dev/null +++ b/hw2/hw/shop_api/routes/carts.py @@ -0,0 +1,113 @@ +from typing import List, Optional +from fastapi import APIRouter, HTTPException, Query, Response + +from ..models import CartResponse, CartItemResponse +from ..storage import storage + +router = APIRouter(prefix="/cart", tags=["carts"]) + + +@router.post("", status_code=201) +def create_cart(response: Response): + cart_id = storage.create_cart() + response.headers["Location"] = f"/cart/{cart_id}" + return {"id": cart_id} + + +@router.get("/{cart_id}", response_model=CartResponse) +def get_cart(cart_id: int): + cart = storage.get_cart_by_id(cart_id) + if not cart: + raise HTTPException(status_code=404, detail="Cart not found") + + cart_items = [] + for item_id, quantity in cart.get("items", {}).items(): + item = storage.get_item_by_id(int(item_id)) + if item: + cart_items.append(CartItemResponse( + id=item["id"], + name=item["name"], + quantity=quantity, + available=not item.get("deleted", False) + )) + + total_price = storage.calculate_cart_price(cart) + + return CartResponse( + id=cart["id"], + items=cart_items, + price=total_price + ) + + +@router.get("", response_model=List[CartResponse]) +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) +): + carts = storage.get_all_carts() + + if min_price is not None or max_price is not None: + filtered_carts = [] + for cart in carts: + cart_price = storage.calculate_cart_price(cart) + if min_price is not None and cart_price < min_price: + continue + if max_price is not None and cart_price > max_price: + continue + filtered_carts.append(cart) + carts = filtered_carts + + if min_quantity is not None or max_quantity is not None: + filtered_carts = [] + for cart in carts: + total_quantity = sum(cart.get("items", {}).values()) + 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 = filtered_carts + + carts = carts[offset:offset + limit] + + result = [] + for cart in carts: + cart_items = [] + for item_id, quantity in cart.get("items", {}).items(): + item = storage.get_item_by_id(int(item_id)) + if item: + cart_items.append(CartItemResponse( + id=item["id"], + name=item["name"], + quantity=quantity, + available=not item.get("deleted", False) + )) + + total_price = storage.calculate_cart_price(cart) + + result.append(CartResponse( + id=cart["id"], + items=cart_items, + price=total_price + )) + + return result + + +@router.post("/{cart_id}/add/{item_id}", status_code=200) +def add_item_to_cart(cart_id: int, item_id: int): + cart = storage.get_cart_by_id(cart_id) + if not cart: + raise HTTPException(status_code=404, detail="Cart not found") + + item = storage.get_item_by_id(item_id) + if not item or item.get("deleted", False): + raise HTTPException(status_code=404, detail="Item not found") + + storage.add_item_to_cart(cart_id, item_id) + return {"message": "Item added to cart"} diff --git a/hw2/hw/shop_api/routes/items.py b/hw2/hw/shop_api/routes/items.py new file mode 100644 index 00000000..88412e2c --- /dev/null +++ b/hw2/hw/shop_api/routes/items.py @@ -0,0 +1,82 @@ +from typing import List, Optional +from fastapi import APIRouter, HTTPException, Query + +from ..models import ItemCreate, ItemUpdate, ItemPatch, ItemResponse +from ..storage import storage + +router = APIRouter(prefix="/item", tags=["items"]) + + +@router.post("", response_model=ItemResponse, status_code=201) +def create_item(item: ItemCreate): + item_id = storage.create_item(item.model_dump()) + return ItemResponse(**storage.get_item_by_id(item_id)) + + +@router.get("/{item_id}", response_model=ItemResponse) +def get_item(item_id: int): + item = storage.get_item_by_id(item_id) + if not item or item.get("deleted", False): + raise HTTPException(status_code=404, detail="Item not found") + + return ItemResponse(**item) + + +@router.get("", response_model=List[ItemResponse]) +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 = Query(False) +): + items = storage.get_all_items() + + if not show_deleted: + items = [item for item in items if not item.get("deleted", False)] + + if min_price is not None: + items = [item for item in items if item["price"] >= min_price] + + if max_price is not None: + items = [item for item in items if item["price"] <= max_price] + + items = items[offset:offset + limit] + + return [ItemResponse(**item) for item in items] + + +@router.put("/{item_id}", response_model=ItemResponse) +def update_item(item_id: int, item_update: ItemUpdate): + item = storage.get_item_by_id(item_id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + + storage.update_item(item_id, item_update.model_dump()) + return ItemResponse(**storage.get_item_by_id(item_id)) + + +@router.patch("/{item_id}", response_model=ItemResponse) +def patch_item(item_id: int, item_patch: ItemPatch): + item = storage.get_item_by_id(item_id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + + if item.get("deleted", False): + raise HTTPException(status_code=304, detail="Item is deleted") + + patch_data = item_patch.model_dump(exclude_unset=True) + # Persist only provided fields + storage.update_item(item_id, patch_data) + updated = storage.get_item_by_id(item_id) + return ItemResponse(**updated) + + +@router.delete("/{item_id}", status_code=200) +def delete_item(item_id: int): + item = storage.get_item_by_id(item_id) + if not item: + raise HTTPException(status_code=404, detail="Item not found") + + storage.delete_item(item_id) + return {"message": "Item deleted"} diff --git a/hw2/hw/shop_api/storage.py b/hw2/hw/shop_api/storage.py new file mode 100644 index 00000000..510868d3 --- /dev/null +++ b/hw2/hw/shop_api/storage.py @@ -0,0 +1,189 @@ +from typing import Any, Dict, Optional +import os + +from sqlalchemy import ( + create_engine, + MetaData, + Table, + Column, + Integer, + String, + Boolean, + Numeric, + ForeignKey, + select, + insert, + update as sa_update, + func, +) +from sqlalchemy.engine import Engine + + +class Storage: + def __init__(self): + database_url = os.getenv( + "DATABASE_URL", + "postgresql+psycopg2://shop_user:shop_password@localhost:5432/shop_db", + ) + print(f"Database URL: {database_url}") + self.engine: Engine = create_engine(database_url, future=True) + self.metadata = MetaData() + + self.items = Table( + "items", + self.metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("name", String, nullable=False), + Column("price", Numeric, nullable=False), + Column("deleted", Boolean, nullable=False, server_default="false"), + ) + + self.carts = Table( + "carts", + self.metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + ) + + self.cart_items = Table( + "cart_items", + self.metadata, + Column("cart_id", Integer, ForeignKey("carts.id", ondelete="CASCADE"), primary_key=True), + Column("item_id", Integer, ForeignKey("items.id", ondelete="CASCADE"), primary_key=True), + Column("quantity", Integer, nullable=False), + ) + + with self.engine.begin() as conn: + self.metadata.create_all(conn) + + def get_item_by_id(self, item_id: int) -> Optional[Dict[str, Any]]: + with self.engine.begin() as conn: + row = conn.execute( + select( + self.items.c.id, + self.items.c.name, + self.items.c.price, + self.items.c.deleted, + ).where(self.items.c.id == item_id) + ).mappings().first() + return dict(row) if row else None + + def get_cart_by_id(self, cart_id: int) -> Optional[Dict[str, Any]]: + with self.engine.begin() as conn: + cart_row = conn.execute( + select(self.carts.c.id).where(self.carts.c.id == cart_id) + ).mappings().first() + if not cart_row: + return None + items_rows = conn.execute( + select(self.cart_items.c.item_id, self.cart_items.c.quantity).where( + self.cart_items.c.cart_id == cart_id + ) + ).all() + items_map: Dict[str, int] = {str(r.item_id): r.quantity for r in items_rows} + return {"id": cart_row["id"], "items": items_map} + + def create_item(self, item_data: Dict[str, Any]) -> int: + with self.engine.begin() as conn: + result = conn.execute( + insert(self.items).values( + name=item_data["name"], price=item_data["price"], deleted=False + ).returning(self.items.c.id) + ) + new_id = result.scalar_one() + return int(new_id) + + def create_cart(self) -> int: + with self.engine.begin() as conn: + result = conn.execute(insert(self.carts).values().returning(self.carts.c.id)) + new_id = result.scalar_one() + return int(new_id) + + def update_item(self, item_id: int, update_data: Dict[str, Any]) -> None: + values: Dict[str, Any] = {} + if "name" in update_data and update_data["name"] is not None: + values["name"] = update_data["name"] + if "price" in update_data and update_data["price"] is not None: + values["price"] = update_data["price"] + if not values: + return + with self.engine.begin() as conn: + conn.execute( + sa_update(self.items).where(self.items.c.id == item_id).values(**values) + ) + + def delete_item(self, item_id: int) -> None: + with self.engine.begin() as conn: + conn.execute( + sa_update(self.items) + .where(self.items.c.id == item_id) + .values(deleted=True) + ) + + def add_item_to_cart(self, cart_id: int, item_id: int) -> None: + with self.engine.begin() as conn: + current = conn.execute( + select(self.cart_items.c.quantity).where( + (self.cart_items.c.cart_id == cart_id) + & (self.cart_items.c.item_id == item_id) + ) + ).scalar_one_or_none() + if current is None: + conn.execute( + insert(self.cart_items).values( + cart_id=cart_id, item_id=item_id, quantity=1 + ) + ) + else: + conn.execute( + sa_update(self.cart_items) + .where( + (self.cart_items.c.cart_id == cart_id) + & (self.cart_items.c.item_id == item_id) + ) + .values(quantity=current + 1) + ) + + def get_all_items(self) -> list[Dict[str, Any]]: + with self.engine.begin() as conn: + rows = conn.execute( + select( + self.items.c.id, self.items.c.name, self.items.c.price, self.items.c.deleted + ) + ).mappings().all() + return [dict(r) for r in rows] + + def get_all_carts(self) -> list[Dict[str, Any]]: + with self.engine.begin() as conn: + cart_rows = conn.execute(select(self.carts.c.id)).mappings().all() + result: list[Dict[str, Any]] = [] + for row in cart_rows: + cart_id = row["id"] + items_rows = conn.execute( + select(self.cart_items.c.item_id, self.cart_items.c.quantity).where( + self.cart_items.c.cart_id == cart_id + ) + ).all() + items_map: Dict[str, int] = {str(r.item_id): r.quantity for r in items_rows} + result.append({"id": cart_id, "items": items_map}) + return result + + def calculate_cart_price(self, cart: Dict[str, Any]) -> float: + if not cart.get("items"): + return 0.0 + item_ids = [int(k) for k in cart["items"].keys()] + with self.engine.begin() as conn: + rows = conn.execute( + select(self.items.c.id, self.items.c.price, self.items.c.deleted).where( + self.items.c.id.in_(item_ids) + ) + ).all() + price_map = {r.id: (float(r.price), bool(r.deleted)) for r in rows} + total = 0.0 + for item_id_str, quantity in cart["items"].items(): + item_id_int = int(item_id_str) + if item_id_int in price_map and not price_map[item_id_int][1]: + total += price_map[item_id_int][0] * int(quantity) + return total + + +storage = Storage() diff --git a/hw2/hw/test_additional_api.py b/hw2/hw/test_additional_api.py new file mode 100644 index 00000000..cb16fa18 --- /dev/null +++ b/hw2/hw/test_additional_api.py @@ -0,0 +1,118 @@ +from http import HTTPStatus + +import pytest +from fastapi.testclient import TestClient + +from shop_api.main import app + + +client = TestClient(app) + + +def test_get_unknown_item_returns_404(): + resp = client.get("/item/999999") + assert resp.status_code == HTTPStatus.NOT_FOUND + + +def test_add_item_and_fetch_list_filters_min_max_price_and_pagination(): + # Create several items + ids = [] + for i in range(5): + r = client.post("/item", json={"name": f"it{i}", "price": 10.0 + i}) + assert r.status_code == HTTPStatus.CREATED + ids.append(r.json()["id"]) + + # min_price + r = client.get("/item", params={"min_price": 12.0}) + assert r.status_code == HTTPStatus.OK + data = r.json() + assert all(item["price"] >= 12.0 for item in data) + + # max_price + r = client.get("/item", params={"max_price": 12.0}) + assert r.status_code == HTTPStatus.OK + data = r.json() + assert all(item["price"] <= 12.0 for item in data) + + # pagination + r1 = client.get("/item", params={"offset": 0, "limit": 2}) + r2 = client.get("/item", params={"offset": 2, "limit": 2}) + assert r1.status_code == HTTPStatus.OK and r2.status_code == HTTPStatus.OK + assert isinstance(r1.json(), list) and isinstance(r2.json(), list) + + +def test_patch_rejects_extra_fields_and_deleted_field(): + # create + r = client.post("/item", json={"name": "x", "price": 11.0}) + item = r.json() + + # extra field -> 422 + r = client.patch(f"/item/{item['id']}", json={"odd": "value"}) + assert r.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + # deleted field not allowed -> 422 + r = client.patch(f"/item/{item['id']}", json={"deleted": True}) + assert r.status_code == HTTPStatus.UNPROCESSABLE_ENTITY + + +def test_cart_unknown_returns_404(): + r = client.get("/cart/999999") + assert r.status_code == HTTPStatus.NOT_FOUND + + +def test_add_nonexistent_item_to_cart_404(): + # create cart + cart_id = client.post("/cart").json()["id"] + r = client.post(f"/cart/{cart_id}/add/999999") + assert r.status_code == HTTPStatus.NOT_FOUND + + +def test_cart_price_excludes_deleted_items(): + # create item and cart + item_resp = client.post("/item", json={"name": "to-delete", "price": 33.0}) + item_id = item_resp.json()["id"] + cart_id = client.post("/cart").json()["id"] + + # add and verify price + client.post(f"/cart/{cart_id}/add/{item_id}") + r = client.get(f"/cart/{cart_id}") + assert r.status_code == HTTPStatus.OK + assert r.json()["price"] == pytest.approx(33.0) + + # delete item and cart price should drop to 0 + client.delete(f"/item/{item_id}") + r = client.get(f"/cart/{cart_id}") + assert r.status_code == HTTPStatus.OK + assert r.json()["price"] == pytest.approx(0.0) + + +@pytest.mark.parametrize( + ("params", "status"), + [ + ({"offset": -5}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_item_list_validation_errors(params, status): + r = client.get("/item", params=params) + assert r.status_code == status + + +@pytest.mark.parametrize( + ("params", "status"), + [ + ({"offset": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"limit": 0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_price": -1.0}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"min_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ({"max_quantity": -1}, HTTPStatus.UNPROCESSABLE_ENTITY), + ], +) +def test_cart_list_validation_errors(params, status): + r = client.get("/cart", params=params) + assert r.status_code == status + + diff --git a/hw2/hw/transsaction_simulations/common.py b/hw2/hw/transsaction_simulations/common.py new file mode 100644 index 00000000..fc18c3a3 --- /dev/null +++ b/hw2/hw/transsaction_simulations/common.py @@ -0,0 +1,74 @@ +import os +import time +from contextlib import contextmanager + +from sqlalchemy import ( + create_engine, + MetaData, + Table, + Column, + Integer, + String, + Integer, + select, + insert, + text, +) +from sqlalchemy.engine import Engine + + +DATABASE_URL = os.getenv( + "DATABASE_URL", + "postgresql+psycopg2://shop_user:shop_password@localhost:5432/shop_db", +) + + +def make_engine(isolation_level: str) -> Engine: + return create_engine( + DATABASE_URL, + future=True, + isolation_level=isolation_level, + pool_pre_ping=True, + ) + + +metadata = MetaData() + +products = Table( + "tx_products", + metadata, + Column("id", Integer, primary_key=True, autoincrement=True), + Column("name", String, nullable=False), + Column("price", Integer, nullable=False), +) + +@contextmanager +def begin_conn(engine: Engine): + conn = engine.connect() + trans = conn.begin() + try: + yield conn + trans.commit() + finally: + conn.close() + + +def reset_demo_data(): + admin_engine = create_engine(DATABASE_URL, future=True) + with admin_engine.begin() as conn: + conn.exec_driver_sql("DROP TABLE IF EXISTS tx_products") + metadata.create_all(conn) + + conn.execute( + insert(products), + [ + {"name": "A", "price": 100}, + {"name": "B", "price": 200}, + ], + ) + + +def sleep(seconds: float): + time.sleep(seconds) + + diff --git a/hw2/hw/transsaction_simulations/read_committed_non_repeatable.py b/hw2/hw/transsaction_simulations/read_committed_non_repeatable.py new file mode 100644 index 00000000..ad67485e --- /dev/null +++ b/hw2/hw/transsaction_simulations/read_committed_non_repeatable.py @@ -0,0 +1,57 @@ +""" +Demonstrate non-repeatable read at READ COMMITTED in PostgreSQL. + +Tx A (READ COMMITTED): + - SELECT price of product B + - Sleep, then SELECT price again -> sees updated price + +Tx B (READ COMMITTED): + - UPDATE price of product B, COMMIT +""" + +from threading import Thread + +from sqlalchemy import select, update + +from tx_demos.common import make_engine, products, begin_conn, reset_demo_data, sleep + + +def tx_a(): + eng = make_engine("READ COMMITTED") + with begin_conn(eng) as conn: + price1 = conn.execute( + select(products.c.price).where(products.c.name == "B") + ).scalar_one() + print(f"Tx A first read price(B) = {price1}") + sleep(1.0) + price2 = conn.execute( + select(products.c.price).where(products.c.name == "B") + ).scalar_one() + print(f"Tx A second read price(B) = {price2}") + + +def tx_b(): + eng = make_engine("READ COMMITTED") + with begin_conn(eng) as conn: + conn.execute( + update(products).where(products.c.name == "B").values(price=250) + ) + print("Tx B updated price(B) to 250 and committed") + + +if __name__ == "__main__": + reset_demo_data() + + t1 = Thread(target=tx_a) + t2 = Thread(target=tx_b) + + t1.start() + sleep(0.3) + t2.start() + + t1.join() + t2.join() + + print("Expect: second read in Tx A differs -> non-repeatable read at READ COMMITTED") + + diff --git a/hw2/hw/transsaction_simulations/read_committed_phantom.py b/hw2/hw/transsaction_simulations/read_committed_phantom.py new file mode 100644 index 00000000..481170b6 --- /dev/null +++ b/hw2/hw/transsaction_simulations/read_committed_phantom.py @@ -0,0 +1,55 @@ +""" +Demonstrate phantom read at READ COMMITTED in PostgreSQL. + +Tx A (READ COMMITTED): + - SELECT count(*) of products with price >= 150 + - Sleep, then SELECT count(*) again -> count changes due to concurrent insert + +Tx B (READ COMMITTED): + - INSERT new product with price 180, COMMIT +""" + +from threading import Thread + +from sqlalchemy import select, func, insert + +from tx_demos.common import make_engine, products, begin_conn, reset_demo_data, sleep + + +def tx_a(): + eng = make_engine("READ COMMITTED") + with begin_conn(eng) as conn: + cnt1 = conn.execute( + select(func.count()).select_from(products).where(products.c.price >= 150) + ).scalar_one() + print(f"Tx A first count (>=150) = {cnt1}") + sleep(1.0) + cnt2 = conn.execute( + select(func.count()).select_from(products).where(products.c.price >= 150) + ).scalar_one() + print(f"Tx A second count (>=150) = {cnt2}") + + +def tx_b(): + eng = make_engine("READ COMMITTED") + with begin_conn(eng) as conn: + conn.execute(insert(products).values(name="C", price=180)) + print("Tx B inserted product C(price=180) and committed") + + +if __name__ == "__main__": + reset_demo_data() + + t1 = Thread(target=tx_a) + t2 = Thread(target=tx_b) + + t1.start() + sleep(0.3) + t2.start() + + t1.join() + t2.join() + + print("Expect: Tx A counts differ -> phantom read at READ COMMITTED") + + diff --git a/hw2/hw/transsaction_simulations/reapetable_read_prevent_non_repeatable.py b/hw2/hw/transsaction_simulations/reapetable_read_prevent_non_repeatable.py new file mode 100644 index 00000000..e640e09d --- /dev/null +++ b/hw2/hw/transsaction_simulations/reapetable_read_prevent_non_repeatable.py @@ -0,0 +1,57 @@ +""" +Prevent non-repeatable read at REPEATABLE READ in PostgreSQL (snapshot isolation). + +Tx A (REPEATABLE READ): + - SELECT price of product B + - Sleep, then SELECT price again -> sees same price as first read + +Tx B (READ COMMITTED): + - UPDATE price of product B, COMMIT +""" + +from threading import Thread + +from sqlalchemy import select, update + +from tx_demos.common import make_engine, products, begin_conn, reset_demo_data, sleep + + +def tx_a(): + eng = make_engine("REPEATABLE READ") + with begin_conn(eng) as conn: + price1 = conn.execute( + select(products.c.price).where(products.c.name == "B") + ).scalar_one() + print(f"Tx A first read price(B) = {price1}") + sleep(1.0) + price2 = conn.execute( + select(products.c.price).where(products.c.name == "B") + ).scalar_one() + print(f"Tx A second read price(B) = {price2}") + + +def tx_b(): + eng = make_engine("READ COMMITTED") + with begin_conn(eng) as conn: + conn.execute( + update(products).where(products.c.name == "B").values(price=260) + ) + print("Tx B updated price(B) to 260 and committed") + + +if __name__ == "__main__": + reset_demo_data() + + t1 = Thread(target=tx_a) + t2 = Thread(target=tx_b) + + t1.start() + sleep(0.3) + t2.start() + + t1.join() + t2.join() + + print("Expect: Tx A reads same value twice at REPEATABLE READ") + + diff --git a/hw2/hw/transsaction_simulations/reapetable_read_prevent_phantom.py b/hw2/hw/transsaction_simulations/reapetable_read_prevent_phantom.py new file mode 100644 index 00000000..1f71dc99 --- /dev/null +++ b/hw2/hw/transsaction_simulations/reapetable_read_prevent_phantom.py @@ -0,0 +1,55 @@ +""" +Prevent phantom read at REPEATABLE READ in PostgreSQL (snapshot isolation). + +Tx A (REPEATABLE READ): + - SELECT count(*) of products with price >= 150 + - Sleep, then SELECT count(*) again -> same count + +Tx B (READ COMMITTED): + - INSERT new product with price 180, COMMIT +""" + +from threading import Thread + +from sqlalchemy import select, func, insert + +from tx_demos.common import make_engine, products, begin_conn, reset_demo_data, sleep + + +def tx_a(): + eng = make_engine("REPEATABLE READ") + with begin_conn(eng) as conn: + cnt1 = conn.execute( + select(func.count()).select_from(products).where(products.c.price >= 150) + ).scalar_one() + print(f"Tx A first count (>=150) = {cnt1}") + sleep(1.0) + cnt2 = conn.execute( + select(func.count()).select_from(products).where(products.c.price >= 150) + ).scalar_one() + print(f"Tx A second count (>=150) = {cnt2}") + + +def tx_b(): + eng = make_engine("READ COMMITTED") + with begin_conn(eng) as conn: + conn.execute(insert(products).values(name="C", price=180)) + print("Tx B inserted product C(price=180) and committed") + + +if __name__ == "__main__": + reset_demo_data() + + t1 = Thread(target=tx_a) + t2 = Thread(target=tx_b) + + t1.start() + sleep(0.3) + t2.start() + + t1.join() + t2.join() + + print("Expect: Tx A counts same -> no phantom at REPEATABLE READ") + + diff --git a/hw2/hw/transsaction_simulations/serializable_conflict.py b/hw2/hw/transsaction_simulations/serializable_conflict.py new file mode 100644 index 00000000..b790a64e --- /dev/null +++ b/hw2/hw/transsaction_simulations/serializable_conflict.py @@ -0,0 +1,48 @@ +""" +Show SERIALIZABLE raising serialization failure for concurrent conflicting transactions. + +Scenario: both transactions read count of expensive products (>= 150) and decide to +insert a new product if count < 3. Running concurrently at SERIALIZABLE should +cause one transaction to fail with SerializationFailure. +""" + +from threading import Thread + +from sqlalchemy import select, func, insert +from sqlalchemy.exc import DBAPIError + +from tx_demos.common import make_engine, products, begin_conn, reset_demo_data, sleep + + +def worker(name: str): + eng = make_engine("SERIALIZABLE") + try: + with begin_conn(eng) as conn: + cnt = conn.execute( + select(func.count()).select_from(products).where(products.c.price >= 150) + ).scalar_one() + print(f"{name}: saw count(>=150) = {cnt}") + if cnt < 3: + conn.execute(insert(products).values(name=f"{name}_X", price=200)) + print(f"{name}: inserted new expensive product") + else: + print(f"{name}: did not insert") + except DBAPIError as e: + print(f"{name}: failed with {type(e.orig).__name__}: {e.orig}") + + +if __name__ == "__main__": + reset_demo_data() + + t1 = Thread(target=worker, args=("Tx1",)) + t2 = Thread(target=worker, args=("Tx2",)) + + t1.start() + t2.start() + + t1.join() + t2.join() + + print("Expect: one transaction commits, the other fails with serialization error") + +