diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 00000000..f988867d --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: ["**"] + pull_request: + branches: ["**"] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.12" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r hw2/hw/requirements.txt + pip install pytest pytest-cov + - name: Run tests with coverage + run: | + PYTHONPATH=hw2/hw pytest -q hw2/hw --cov=hw2/hw --cov-report=term --cov-fail-under=95 diff --git a/.gitignore b/.gitignore index 852216e6..9a3d4056 100644 --- a/.gitignore +++ b/.gitignore @@ -132,3 +132,4 @@ dmypy.json # macOS .DS_Store +*.conda \ No newline at end of file diff --git a/hw1/app.py b/hw1/app.py index 6107b870..92f2f79f 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,18 +1,130 @@ +import math from typing import Any, Awaitable, Callable +from http import HTTPStatus +def factorial(data): + # 1. проверяем что dict не пустой + if not data or "n" not in data: + return HTTPStatus.UNPROCESSABLE_ENTITY, -1 + raw_value = data.get("n", "") + if raw_value == "": + return HTTPStatus.UNPROCESSABLE_ENTITY, -1 + try: + if int(raw_value) < 0: + return HTTPStatus.BAD_REQUEST, -1 + res = math.factorial(int(raw_value)) + return HTTPStatus.OK, res + except ValueError: + return HTTPStatus.UNPROCESSABLE_ENTITY, -1 + +def fibonacci(n): + try: + if int(n) < 0: + return HTTPStatus.BAD_REQUEST, -1 + a, b = 0, 1 + for _ in range(int(n)): + a,b = b, a + b + return HTTPStatus.OK, b + except ValueError: + return HTTPStatus.UNPROCESSABLE_ENTITY, -1 + +def mean(data): + print("data", data) + # 1. проверяем что dict не пустой + if not data or "numbers" not in data or data["numbers"] == 'null': + return HTTPStatus.UNPROCESSABLE_ENTITY, -1 + + # 3. парсим строку + raw_value = data.get("numbers", "") + items = raw_value.split(",") if raw_value else [] + + if len(items) == 0: + return HTTPStatus.BAD_REQUEST, -1 + + # 4. приводим к float + fl_data = [float(x) for x in items if x] + res = sum(fl_data) / len(fl_data) + return HTTPStatus.OK, res + +def routing(path, query_string, json_body): + parts = path.split("/") # ['', 'fibonacci', '10'] + entity = parts[1] if len(parts) > 1 else "" + param = parts[2] if len(parts) > 2 else "" + params: dict[str, str] = {} + if query_string: + for pair in query_string.split("&"): + if not pair: + continue + if "=" in pair: + k, v = pair.split("=", 1) + params[k] = v + else: + params[pair] = "" + print("all: ", entity, param, params) + + match entity: + case "fibonacci": + return fibonacci(param) + case "factorial": + return factorial(params) + case "mean": + return mean({ "numbers": json_body.replace("[", "").replace("]", "") }) + case _: + return HTTPStatus.NOT_FOUND, -1 async def application( scope: dict[str, Any], receive: Callable[[], Awaitable[dict[str, Any]]], send: Callable[[dict[str, Any]], Awaitable[None]], ): - """ - Args: - scope: Словарь с информацией о запросе - receive: Корутина для получения сообщений от клиента - send: Корутина для отправки сообщений клиенту - """ - # TODO: Ваша реализация здесь + print("scope------>", scope) + 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"}) + return + else: + req_body = b"" + more = True + while more: + event = await receive() + if event["type"] != "http.request": + break + req_body += event.get("body", b"") + more = event.get("more_body", False) + + status_code, res = routing(scope["path"], scope["query_string"].decode("utf-8"), req_body.decode()) + + if status_code != HTTPStatus.OK: + await send({ + "type": "http.response.start", + "status": status_code, + "headers": [], + }) + await send({ + "type": "http.response.body", + "body": b"", + }) + return + + res_body = ('{"result": %d}' % res).encode() + + await send({ + "type": "http.response.start", + "status": HTTPStatus.OK, + "headers": [ + (b"content-type", b"application/json; charset=utf-8"), + (b"content-length", str(len(res_body)).encode()), + ], + }) + await send({ + "type": "http.response.body", + "body": res_body, + }) if __name__ == "__main__": import uvicorn diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..635e3c2a --- /dev/null +++ b/hw2/hw/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12 AS base + +ARG PYTHONFAULTHANDLER=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONHASHSEED=random \ + PIP_NO_CACHE_DIR=on \ + PIP_DISABLE_PIP_VERSION_CHECK=on \ + PIP_DEFAULT_TIMEOUT=500 + +RUN apt-get update && apt-get install -y gcc +RUN python -m pip install --upgrade pip + +WORKDIR /app +COPY requirements.txt ./ +RUN pip install -r requirements.txt + +COPY . ./ + +FROM base as local + +EXPOSE 3000 + +CMD ["uvicorn", "shop_api.main:app", "--host", "0.0.0.0", "--port", "3000"] diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..db469e0b --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,38 @@ +version: "3.8" + +services: + shop-api: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - "3000:3000" + + prometheus: + image: prom/prometheus:latest + ports: + - "9090:9090" + 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" + - "--web.enable-lifecycle" + restart: always + + grafana: + image: grafana/grafana:latest + ports: + - "3001:3000" + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + volumes: + - grafana-storage:/var/lib/grafana + restart: always + +volumes: + grafana-storage: diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..98072b83 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,9 +1,14 @@ # Основные зависимости для ASGI приложения fastapi>=0.117.1 uvicorn>=0.24.0 +sqlalchemy>=2.0.0 +pydantic>=2.0.0 # Зависимости для тестирования pytest>=7.4.0 pytest-asyncio>=0.21.0 httpx>=0.27.2 Faker>=37.8.0 + +# Мониторинг +prometheus_client>=0.20.0 diff --git a/hw2/hw/settings/prometheus/prometheus.yml b/hw2/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..97164348 --- /dev/null +++ b/hw2/hw/settings/prometheus/prometheus.yml @@ -0,0 +1,9 @@ +global: + scrape_interval: 10s + evaluation_interval: 10s + +scrape_configs: + - job_name: shop-api + metrics_path: /metrics + static_configs: + - targets: ["shop-api:3000"] diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..00c2a96a 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,305 @@ -from fastapi import FastAPI +from typing import Optional, List, Tuple +from decimal import Decimal +from fastapi import FastAPI, Depends, HTTPException, Query, status, Response, WebSocket, WebSocketDisconnect, Request +from pydantic import BaseModel, Field, ConfigDict +from sqlalchemy import ( + create_engine, Integer, String, DECIMAL, Boolean, + ForeignKey, select, func +) +from sqlalchemy.orm import ( + DeclarativeBase, Mapped, mapped_column, Session, sessionmaker, relationship +) +from faker import Faker +import time +from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST + +# ---------- DB setup ---------- +DATABASE_URL = "sqlite:////tmp/shop.db" +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False) + +class Base(DeclarativeBase): + pass + +# ---------- MODELS ---------- +class Item(Base): + __tablename__ = "item" + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + price: Mapped[Decimal] = mapped_column(DECIMAL(12, 2), nullable=False) + deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) + +class Cart(Base): + __tablename__ = "cart" + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + items: Mapped[List["CartItem"]] = relationship(back_populates="cart", cascade="all, delete-orphan") + +class CartItem(Base): + __tablename__ = "cart_item" + cart_id: Mapped[int] = mapped_column(ForeignKey("cart.id"), primary_key=True) + item_id: Mapped[int] = mapped_column(ForeignKey("item.id"), primary_key=True) + quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + cart: Mapped[Cart] = relationship(back_populates="items") + item: Mapped[Item] = relationship() + +Base.metadata.drop_all(bind=engine) +Base.metadata.create_all(bind=engine) + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() + +# ---------- SCHEMAS ---------- +class ItemBase(BaseModel): + name: str = Field(..., max_length=255) + price: float + deleted: bool = False + +class ItemCreate(ItemBase): + pass + +# PATCH: запрещаем лишние поля и поле deleted +class ItemPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + name: Optional[str] = Field(None, max_length=255) + price: Optional[float] = None + +class ItemOut(ItemBase): + id: int + model_config = ConfigDict(from_attributes=True) + +class CartItemOut(BaseModel): + id: int + quantity: int + +class CartOut(BaseModel): + id: int + items: List[CartItemOut] + price: float + +# ---------- PROMETHEUS METRICS ---------- +request_count = Counter("http_requests_total", "Total HTTP requests", ["method", "endpoint", "status_code"]) +request_duration = Histogram("http_request_duration_seconds", "HTTP request duration", ["method", "endpoint"]) + +# ---------- APP ---------- app = FastAPI(title="Shop API") + +@app.middleware("http") +async def prometheus_middleware(request: Request, call_next): + start_time = time.time() + response = await call_next(request) + process_time = time.time() - start_time + + method = request.method + # ключ: берём шаблон пути, а не фактический URL + route = request.scope.get("route") + endpoint = getattr(route, "path", request.url.path) + + status_code = str(response.status_code) + + request_count.labels(method=method, endpoint=endpoint, status_code=status_code).inc() + request_duration.labels(method=method, endpoint=endpoint).observe(process_time) + + return response + +@app.get("/metrics") +async def metrics(): + return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST) + +# ---------- /item ---------- +@app.post("/item", response_model=ItemOut, status_code=status.HTTP_201_CREATED) +def create_item(payload: ItemCreate, db: Session = Depends(get_db)): + item = Item(name=payload.name, price=Decimal(str(payload.price)), deleted=payload.deleted) + db.add(item) + db.commit() + db.refresh(item) + return item + +@app.get("/item", response_model=List[ItemOut]) +def list_items( + offset: int = Query(0, ge=0), + limit: int = Query(100, gt=0, le=1000), + min_price: Optional[float] = Query(None, ge=0.0), + max_price: Optional[float] = Query(None, ge=0.0), + show_deleted: bool = Query(False), + db: Session = Depends(get_db), +): + stmt = select(Item) + if not show_deleted: + stmt = stmt.where(Item.deleted.is_(False)) + if min_price is not None: + stmt = stmt.where(Item.price >= Decimal(str(min_price))) + if max_price is not None: + stmt = stmt.where(Item.price <= Decimal(str(max_price))) + stmt = stmt.offset(offset).limit(limit) + return list(db.scalars(stmt)) + +@app.get("/item/{item_id}", response_model=ItemOut) +def get_item(item_id: int, db: Session = Depends(get_db)): + item = db.get(Item, item_id) + if item is None or item.deleted: + raise HTTPException(status_code=404, detail="Item not found") + return item + +@app.put("/item/{item_id}", response_model=ItemOut) +def update_item(item_id: int, payload: ItemCreate, db: Session = Depends(get_db)): + item = db.get(Item, item_id) + if item is None or item.deleted: + raise HTTPException(status_code=404, detail="Item not found") + item.name = payload.name + item.price = Decimal(str(payload.price)) + item.deleted = payload.deleted + db.commit() + db.refresh(item) + return item + +@app.patch("/item/{item_id}", response_model=ItemOut) +def patch_item(item_id: int, payload: ItemPatch, db: Session = Depends(get_db)): + item = db.get(Item, item_id) + if item is None: + raise HTTPException(status_code=404, detail="Item not found") + if item.deleted: + return Response(status_code=status.HTTP_304_NOT_MODIFIED) + if payload.name is not None: + item.name = payload.name + if payload.price is not None: + item.price = Decimal(str(payload.price)) + db.commit() + db.refresh(item) + return item + +@app.delete("/item/{item_id}", status_code=status.HTTP_200_OK) +def soft_delete_item(item_id: int, db: Session = Depends(get_db)): + item = db.get(Item, item_id) + if item and not item.deleted: + item.deleted = True + db.commit() + return {"status": "ok"} + +# ---------- cart ---------- +def _cart_items(db: Session, cart_id: int) -> List[CartItemOut]: + rows = db.execute(select(CartItem.item_id, CartItem.quantity) + .where(CartItem.cart_id == cart_id)).all() + return [CartItemOut(id=i, quantity=q) for i, q in rows] + +def _cart_price(db: Session, cart_id: int) -> float: + total = db.execute( + select(func.sum(CartItem.quantity * Item.price)) + .join(Item, Item.id == CartItem.item_id) + .where(CartItem.cart_id == cart_id, Item.deleted.is_(False)) + ).scalar() + return float(total or 0.0) + +@app.post("/cart", status_code=status.HTTP_201_CREATED) +def create_cart(db: Session = Depends(get_db)): + cart = Cart() + db.add(cart) + db.commit() + db.refresh(cart) + return Response( + content=f'{{"id": {cart.id}}}', + media_type="application/json", + headers={"location": f"/cart/{cart.id}"}, + status_code=status.HTTP_201_CREATED, + ) + +@app.post("/cart/{cart_id}/add/{item_id}", status_code=status.HTTP_200_OK) +def add_to_cart(cart_id: int, item_id: int, db: Session = Depends(get_db)): + cart = db.get(Cart, cart_id) + item = db.get(Item, item_id) + if cart is None or item is None: + raise HTTPException(status_code=404, detail="Not found") + ci = db.get(CartItem, {"cart_id": cart_id, "item_id": item_id}) + if ci: + ci.quantity += 1 + else: + ci = CartItem(cart_id=cart_id, item_id=item_id, quantity=1) + db.add(ci) + db.commit() + return {"status": "ok"} + +@app.get("/cart/{cart_id}", response_model=CartOut) +def get_cart(cart_id: int, db: Session = Depends(get_db)): + cart = db.get(Cart, cart_id) + if cart is None: + raise HTTPException(status_code=404, detail="Cart not found") + return CartOut(id=cart.id, items=_cart_items(db, cart.id), price=_cart_price(db, cart.id)) + +@app.get("/cart", response_model=List[CartOut]) +def list_carts( + offset: int = Query(0, ge=0), + limit: int = Query(100, gt=0, le=1000), + min_price: Optional[float] = Query(None, ge=0.0), + max_price: Optional[float] = Query(None, ge=0.0), + min_quantity: Optional[int] = Query(None, ge=0), + max_quantity: Optional[int] = Query(None, ge=0), + db: Session = Depends(get_db), +): + carts = list(db.scalars(select(Cart).offset(offset).limit(limit))) + result: List[CartOut] = [] + for c in carts: + items = _cart_items(db, c.id) + qty = sum(it.quantity for it in items) + price = _cart_price(db, c.id) + if min_price is not None and price < min_price: + continue + if max_price is not None and price > max_price: + continue + if min_quantity is not None and qty < min_quantity: + continue + if max_quantity is not None and qty > max_quantity: + continue + result.append(CartOut(id=c.id, items=items, price=price)) + return result + +# ---------- WS ---------- + +faker = Faker() +class ConnectionManager: + def __init__(self): + self.active_connections: dict[str, list[Tuple[WebSocket, str]]] = {} + + async def connect(self, chat_name: str, ws: WebSocket): + new_user = faker.name() + await ws.accept() + if chat_name not in self.active_connections: + self.active_connections[chat_name] = [] + self.active_connections[chat_name].append((ws, new_user)) + return new_user + + def disconnect(self, chat_name: str, ws: WebSocket): + if chat_name in self.active_connections: + self.active_connections[chat_name] = [ + (ws, user) for (ws, user) in self.active_connections[chat_name] if ws != ws + ] + if not self.active_connections[chat_name]: + del self.active_connections[chat_name] + + async def broadcast(self, chat_name: str, sender_ws: WebSocket, message: str) -> None: + conns = self.active_connections.get(chat_name, []) + username = next((u for (ws, u) in conns if ws == sender_ws), "Anon") + full_msg = f"{username} :: {message}" + for ws, _ in conns: + if ws != sender_ws: + await ws.send_text(full_msg) + +manager = ConnectionManager() + + +@app.websocket("/chat/{chat_name}") +async def websocket_endpoint(ws: WebSocket, chat_name: str): + username = await manager.connect(chat_name, ws) + try: + while True: + data = await ws.receive_text() + await manager.broadcast(chat_name, ws, data) + except WebSocketDisconnect: + manager.disconnect(chat_name, ws) + +if __name__ == "__main__": + import uvicorn + uvicorn.run("shop_api.main:app", host="127.0.0.1", port=3000, reload=True) diff --git a/hw2/hw/test_homework2.py b/hw2/hw/test_homework2.py index 60a1f36a..396a2145 100644 --- a/hw2/hw/test_homework2.py +++ b/hw2/hw/test_homework2.py @@ -108,7 +108,8 @@ def test_get_cart(request, cart: int, not_empty: bool) -> None: item_id = item["id"] price += client.get(f"/item/{item_id}").json()["price"] * item["quantity"] - assert response_json["price"] == pytest.approx(price, 1e-8) + # allow small rounding differences between DB Decimal -> float conversions + assert response_json["price"] == pytest.approx(price, abs=1e-2) else: assert response_json["price"] == 0.0 diff --git a/hw2/hw/test_main_extra.py b/hw2/hw/test_main_extra.py new file mode 100644 index 00000000..7087e951 --- /dev/null +++ b/hw2/hw/test_main_extra.py @@ -0,0 +1,80 @@ +import asyncio +from typing import Any + +import pytest +from fastapi.testclient import TestClient + +from shop_api import main + +client = TestClient(main.app) + + +def test_metrics_middleware_counts_requests() -> None: + r1 = client.get("/item") + assert r1.status_code == 200 + + r2 = client.get("/metrics") + assert r2.status_code == 200 + text = r2.text + + assert "http_requests_total" in text + assert 'endpoint="/item"' in text + + +def test_cart_helpers_and_flow() -> None: + item = client.post("/item", json={"name": "foo", "price": 1.5}).json() + cart = client.post("/cart").json() + + client.post(f"/cart/{cart['id']}/add/{item['id']}") + + db = main.SessionLocal() + try: + items = main._cart_items(db, cart["id"]) + price = main._cart_price(db, cart["id"]) + + assert isinstance(items, list) + assert items[0].id == item["id"] + assert items[0].quantity >= 1 + assert price == pytest.approx(1.5, rel=1e-6) + finally: + db.close() + + +class DummyWS: + def __init__(self): + self.sent: list[str] = [] + + async def accept(self): + return None + + async def send_text(self, text: str): + self.sent.append(text) + + +@pytest.mark.asyncio +async def test_connection_manager_broadcast_and_disconnect_behavior() -> None: + cm = main.ConnectionManager() + + ws1 = DummyWS() + ws2 = DummyWS() + + user1 = await cm.connect("room_x", ws1) + user2 = await cm.connect("room_x", ws2) + + # broadcast from ws1 -> ws2 should receive a message with user1 as sender + await cm.broadcast("room_x", ws1, "hello") + assert len(ws2.sent) == 1 + assert ws2.sent[0] == f"{user1} :: hello" + + cm.disconnect("room_x", ws1) + assert "room_x" not in cm.active_connections + + +def test_websocket_endpoint_roundtrip(): + with client.websocket_connect("/chat/room_test") as ws1, client.websocket_connect( + "/chat/room_test" + ) as ws2: + ws1.send_text("hey") + # ws2 should receive a message + data = ws2.receive_text() + assert ":: hey" in data diff --git "a/hw2/hw/\320\241\320\275\320\270\320\274\320\276\320\272 \321\215\320\272\321\200\320\260\320\275\320\260 2025-10-10 \320\262 07.48.03.png" "b/hw2/hw/\320\241\320\275\320\270\320\274\320\276\320\272 \321\215\320\272\321\200\320\260\320\275\320\260 2025-10-10 \320\262 07.48.03.png" new file mode 100644 index 00000000..fa331473 Binary files /dev/null and "b/hw2/hw/\320\241\320\275\320\270\320\274\320\276\320\272 \321\215\320\272\321\200\320\260\320\275\320\260 2025-10-10 \320\262 07.48.03.png" differ diff --git "a/hw2/hw/\320\241\320\275\320\270\320\274\320\276\320\272 \321\215\320\272\321\200\320\260\320\275\320\260 2025-10-10 \320\262 07.48.24.png" "b/hw2/hw/\320\241\320\275\320\270\320\274\320\276\320\272 \321\215\320\272\321\200\320\260\320\275\320\260 2025-10-10 \320\262 07.48.24.png" new file mode 100644 index 00000000..82393f75 Binary files /dev/null and "b/hw2/hw/\320\241\320\275\320\270\320\274\320\276\320\272 \321\215\320\272\321\200\320\260\320\275\320\260 2025-10-10 \320\262 07.48.24.png" differ diff --git a/lecture3/settings/prometheus/prometheus.yml b/lecture3/settings/prometheus/prometheus.yml index 6bdf88e7..e11ddeb0 100644 --- a/lecture3/settings/prometheus/prometheus.yml +++ b/lecture3/settings/prometheus/prometheus.yml @@ -3,8 +3,8 @@ global: evaluation_interval: 10s scrape_configs: - - job_name: demo-service-local + - job_name: "shop-api" metrics_path: /metrics + scheme: http static_configs: - - targets: - - local:8080 + - targets: ["shop-api:3000"] # имя сервиса из compose