From 959448dc333aaa4ff7a70026de6302ed5cf5a97c Mon Sep 17 00:00:00 2001 From: safroalex Date: Sat, 27 Sep 2025 23:35:40 +0300 Subject: [PATCH 1/5] ASGI: lifespan + router + factorial/fibonacci/mean --- hw1/app.py | 162 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 151 insertions(+), 11 deletions(-) diff --git a/hw1/app.py b/hw1/app.py index 6107b870..d89f0386 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,58 @@ from typing import Any, Awaitable, Callable +from urllib.parse import parse_qs +import json + +# утилита для ответа +async def _respond(send, status: int, payload: dict[str, Any]): + body = json.dumps(payload).encode("utf-8") + await send({ + "type": "http.response.start", + "status": status, + "headers": [[b"content-type", b"application/json"]], + }) + await send({ + "type": "http.response.body", + "body": body, + }) + +def _match_route(path: str) -> str: + if path == "/factorial": + return "factorial" + if path.startswith("/fibonacci"): + return "fibonacci" + if path == "/mean": + return "mean" + return "not_found" + +def fact(n: int) -> int: + r = 1 + for i in range(2, n + 1): + r *= i + return r + +def fib(n: int) -> int: + if n == 0: + return 0 + elif n == 1: + return 1 + a, b = 0, 1 + for _ in range(2, n + 1): + a, b = b, a + b + return b + +async def _read_body(receive) -> bytes: + chunks = [] + while True: + event = await receive() + if event["type"] == "http.request": + if event.get("body"): + chunks.append(event["body"]) + if not event.get("more_body", False): + break + elif event["type"] == "http.disconnect": + return b"" + return b"".join(chunks) + async def application( @@ -6,14 +60,100 @@ async def application( receive: Callable[[], Awaitable[dict[str, Any]]], send: Callable[[dict[str, Any]], Awaitable[None]], ): - """ - Args: - scope: Словарь с информацией о запросе - receive: Корутина для получения сообщений от клиента - send: Корутина для отправки сообщений клиенту - """ - # TODO: Ваша реализация здесь - -if __name__ == "__main__": - import uvicorn - uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) + t = scope["type"] + + # 1) lifespan — нужен TestClient + if t == "lifespan": + while True: + event = await receive() + et = event["type"] + if et == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif et == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}) + return + return + + # 2) http + if t != "http": + return + + method: str = scope["method"] + path: str = scope["path"] + + route = _match_route(path) + + if route == "not_found": + await _respond(send, 404, {"detail": "Not Found"}) + return + + allowed = {"GET"} + if method not in allowed: + await _respond(send, 405, {"detail": "Method Not Allowed"}) + return + + if route == "factorial": + qs_bytes = scope.get("query_string", b"") + qs = parse_qs(qs_bytes.decode("utf-8") if qs_bytes else "", keep_blank_values=True) + + raw = qs.get("n", [None])[0] + + if raw is None or raw == "": + await _respond(send, 422, {"detail": "Unprocessable Entity"}) + return + try: + n = int(raw) + except ValueError: + await _respond(send, 422, {"detail": "Unprocessable Entity"}) + return + if n < 0: + await _respond(send, 400, {"detail": "Bad Request"}) + return + await _respond(send, 200, {"result": fact(n)}) + return + + if route == "fibonacci": + parts = path.split("/") + if len(parts) != 3 or parts[2] == "": + await _respond(send, 422, {"detail": "Unprocessable Entity"}) + return + + raw = parts[2] + + try: + n = int(raw) + except ValueError: + await _respond(send, 422, {"detail": "Unprocessable Entity"}) + return + + if n < 0: + await _respond(send, 400, {"detail": "Bad Request"}) + return + + await _respond(send, 200, {"result": fib(n)}) + return + + + if route == "mean": + raw = await _read_body(receive) + + if not raw or raw.strip() == b"": + await _respond(send, 422, {"detail": "Unprocessable Entity"}); + return + try: + data = json.loads(raw) + except Exception: + await _respond(send, 422, {"detail": "Unprocessable Entity"}); return + + if data is None or not isinstance(data, list): + await _respond(send, 422, {"detail": "Unprocessable Entity"}); return + if len(data) == 0: + await _respond(send, 400, {"detail": "Bad Request"}); return + if not all(isinstance(x, (int, float)) for x in data): + await _respond(send, 400, {"detail": "Bad Request"}); return + + + mean = sum(float(x) for x in data) / len(data) + await _respond(send, 200, {"result": mean}); return + + From 197d17f9c8ebe63062e47a1379af6d11179d0e7f Mon Sep 17 00:00:00 2001 From: safroalex Date: Sun, 5 Oct 2025 23:20:24 +0300 Subject: [PATCH 2/5] Implement item and cart management endpoints in Shop API --- hw2/hw/shop_api/main.py | 192 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 191 insertions(+), 1 deletion(-) diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..ffe4085c 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,193 @@ -from fastapi import FastAPI +from http import HTTPStatus +from typing import Dict, List, Optional + +from fastapi import FastAPI, HTTPException, Query, Response +from pydantic import BaseModel, Field, NonNegativeInt, PositiveInt, PositiveFloat, ConfigDict app = FastAPI(title="Shop API") + +# ---- Models ---- + +class Item(BaseModel): + id: int + name: str + price: float + deleted: bool = False + +class ItemCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str + price: PositiveFloat + +class ItemPut(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str + price: PositiveFloat + +class ItemPatch(BaseModel): + model_config = ConfigDict(extra="forbid") # лишние поля -> 422 + name: Optional[str] = None + price: Optional[PositiveFloat] = None + +# ---- In-memory stores ---- + +_items: Dict[int, Item] = {} +_item_id = 0 + +def _next_item_id() -> int: + global _item_id + _item_id += 1 + return _item_id + +_carts: Dict[int, Dict[int, int]] = {} +_cart_id = 0 + +def _next_cart_id() -> int: + global _cart_id + _cart_id += 1 + return _cart_id + +def _item_visible(item: Item, show_deleted: bool) -> bool: + return show_deleted or not item.deleted + +# ---- Item endpoints ---- + +@app.post("/item", status_code=HTTPStatus.CREATED) +async def create_item(body: ItemCreate): + item_id = _next_item_id() + item = Item(id=item_id, name=body.name, price=float(body.price), deleted=False) + _items[item_id] = item + return item.model_dump() + +@app.get("/item/{id}") +async def get_item(id: int): + item = _items.get(id) + if item is None or item.deleted: + raise HTTPException(HTTPStatus.NOT_FOUND) + return item.model_dump() + +@app.get("/item") +async def list_items( + offset: NonNegativeInt = 0, + limit: Optional[PositiveInt] = None, + min_price: Optional[float] = Query(default=None, ge=0.0), + max_price: Optional[float] = Query(default=None, ge=0.0), + show_deleted: bool = False, +): + items = [i for i in _items.values() if _item_visible(i, show_deleted)] + if min_price is not None: + items = [i for i in items if i.price >= float(min_price)] + if max_price is not None: + items = [i for i in items if i.price <= float(max_price)] + items.sort(key=lambda x: x.id) + sliced = items[offset:] if limit is None else items[offset : offset + limit] + return [i.model_dump() for i in sliced] + +@app.put("/item/{id}") +async def put_item(id: int, body: ItemPut): + item = _items.get(id) + if item is None or item.deleted: + raise HTTPException(HTTPStatus.NOT_MODIFIED) + item.name = body.name + item.price = float(body.price) + _items[id] = item + return item.model_dump() + +@app.patch("/item/{id}") +async def patch_item(id: int, body: ItemPatch): + item = _items.get(id) + if item is None or item.deleted: + raise HTTPException(HTTPStatus.NOT_MODIFIED) + updates = body.model_dump(exclude_unset=True) + if not updates: + return item.model_dump() + if "name" in updates: + item.name = updates["name"] + if "price" in updates: + item.price = float(updates["price"]) + _items[id] = item + return item.model_dump() + +@app.delete("/item/{id}") +async def delete_item(id: int): + item = _items.get(id) + if item is None: + return Response("") + if not item.deleted: + item.deleted = True + _items[id] = item + return Response("") + +# ---- Cart helpers ---- + +def _cart_total_price(cart_items: Dict[int, int]) -> float: + total = 0.0 + for item_id, qty in cart_items.items(): + item = _items.get(item_id) + if item and not item.deleted: + total += item.price * qty + return float(total) + +def _cart_items_repr(cart_items: Dict[int, int]) -> List[dict]: + return [{"id": iid, "quantity": qty} for iid, qty in cart_items.items()] + +# ---- Cart endpoints ---- + +@app.post("/cart", status_code=HTTPStatus.CREATED) +async def create_cart(response: Response): + cart_id = _next_cart_id() + _carts[cart_id] = {} + response.headers["Location"] = f"/cart/{cart_id}" + return {"id": cart_id} + +@app.get("/cart/{id}") +async def get_cart(id: int): + cart = _carts.get(id) + if cart is None: + raise HTTPException(HTTPStatus.NOT_FOUND) + return { + "id": id, + "items": _cart_items_repr(cart), + "price": _cart_total_price(cart), + } + +@app.post("/cart/{id}/add/{item_id}") +async def add_item_to_cart(id: int, item_id: int): + cart = _carts.get(id) + if cart is None: + raise HTTPException(HTTPStatus.NOT_FOUND) + cart[item_id] = cart.get(item_id, 0) + 1 + return {"id": id, "items": _cart_items_repr(cart), "price": _cart_total_price(cart)} + +@app.get("/cart") +async def list_carts( + offset: NonNegativeInt = 0, + limit: Optional[PositiveInt] = None, + min_price: Optional[float] = Query(default=None, ge=0.0), + max_price: Optional[float] = Query(default=None, ge=0.0), + min_quantity: Optional[NonNegativeInt] = None, + max_quantity: Optional[NonNegativeInt] = None, +): + carts_list = sorted(_carts.items(), key=lambda x: x[0]) + filtered: List[tuple[int, Dict[int, int]]] = [] + for cid, cart in carts_list: + price = _cart_total_price(cart) + if min_price is not None and price < float(min_price): + continue + if max_price is not None and price > float(max_price): + continue + filtered.append((cid, cart)) + + if min_quantity is not None or max_quantity is not None: + agg = 0 + constrained: List[tuple[int, Dict[int, int]]] = [] + for cid, cart in filtered: + cart_qty = sum(cart.values()) + if max_quantity is not None and agg + cart_qty > max_quantity: + break + constrained.append((cid, cart)) + agg += cart_qty + filtered = constrained + + sliced = filtered[offset:] if limit is None else filtered[offset : offset + limit] + return [{"id": cid, "items": _cart_items_repr(cart), "price": _cart_total_price(cart)} for cid, cart in sliced] From 785097f72ccca17180f7d0cfe81ea5dbdf64053e Mon Sep 17 00:00:00 2001 From: safroalex Date: Tue, 21 Oct 2025 14:22:48 +0300 Subject: [PATCH 3/5] add hw4 --- lecture4/1_raw_asyncpg/main.py | 61 ------- lecture4/1_raw_asyncpg/requirements.txt | 2 - lecture4/2_active_record/main.py | 74 -------- lecture4/2_active_record/requirements.txt | 3 - lecture4/3_data_mapper_sqlalchemy/main.py | 156 ----------------- .../3_data_mapper_sqlalchemy/requirements.txt | 3 - lecture4/4_edgedb/.gitignore | 112 ------------- lecture4/4_edgedb/README.md | 76 --------- lecture4/4_edgedb/dbschema/default.gel | 55 ------ lecture4/4_edgedb/edgedb.toml | 3 - .../queries/check_product_for_order.edgeql | 7 - lecture4/4_edgedb/queries/create_order.edgeql | 11 -- .../4_edgedb/queries/create_product.edgeql | 7 - lecture4/4_edgedb/queries/create_user.edgeql | 6 - lecture4/4_edgedb/queries/delete_user.edgeql | 3 - lecture4/4_edgedb/requirements.txt | 2 - lecture4/Dockerfile | 7 + lecture4/README.md | 74 +++++++- lecture4/database.py | 14 ++ lecture4/docker-compose.yml | 49 +++--- lecture4/init.sql | 25 +++ lecture4/main.py | 158 ++++++++++++++++++ lecture4/models.py | 21 +++ lecture4/requirements.txt | 5 + lecture4/scripts/dirty_read_pg.py | 25 +++ lecture4/scripts/nonrepeatable_pg.py | 52 ++++++ lecture4/scripts/phantom_pg.py | 64 +++++++ lecture4/scripts/util.py | 14 ++ 28 files changed, 482 insertions(+), 607 deletions(-) delete mode 100644 lecture4/1_raw_asyncpg/main.py delete mode 100644 lecture4/1_raw_asyncpg/requirements.txt delete mode 100644 lecture4/2_active_record/main.py delete mode 100644 lecture4/2_active_record/requirements.txt delete mode 100644 lecture4/3_data_mapper_sqlalchemy/main.py delete mode 100644 lecture4/3_data_mapper_sqlalchemy/requirements.txt delete mode 100644 lecture4/4_edgedb/.gitignore delete mode 100644 lecture4/4_edgedb/README.md delete mode 100644 lecture4/4_edgedb/dbschema/default.gel delete mode 100644 lecture4/4_edgedb/edgedb.toml delete mode 100644 lecture4/4_edgedb/queries/check_product_for_order.edgeql delete mode 100644 lecture4/4_edgedb/queries/create_order.edgeql delete mode 100644 lecture4/4_edgedb/queries/create_product.edgeql delete mode 100644 lecture4/4_edgedb/queries/create_user.edgeql delete mode 100644 lecture4/4_edgedb/queries/delete_user.edgeql delete mode 100644 lecture4/4_edgedb/requirements.txt create mode 100644 lecture4/Dockerfile create mode 100644 lecture4/database.py create mode 100644 lecture4/init.sql create mode 100644 lecture4/main.py create mode 100644 lecture4/models.py create mode 100644 lecture4/requirements.txt create mode 100644 lecture4/scripts/dirty_read_pg.py create mode 100644 lecture4/scripts/nonrepeatable_pg.py create mode 100644 lecture4/scripts/phantom_pg.py create mode 100644 lecture4/scripts/util.py diff --git a/lecture4/1_raw_asyncpg/main.py b/lecture4/1_raw_asyncpg/main.py deleted file mode 100644 index 35439b2d..00000000 --- a/lecture4/1_raw_asyncpg/main.py +++ /dev/null @@ -1,61 +0,0 @@ -import asyncpg -from typing import Optional, List - - -class UserRepository: - """Простой репозиторий для работы с пользователями через asyncpg""" - - def __init__(self, connection_string: str): - self.connection_string = connection_string - self.pool: Optional[asyncpg.Pool] = None - - async def initialize(self): - """Инициализация пула соединений""" - self.pool = await asyncpg.create_pool(self.connection_string, min_size=2, max_size=10) - - async def close(self): - """Закрытие пула""" - if self.pool: - await self.pool.close() - - async def create_user(self, email: str, name: str, age: int) -> int: - """Создание нового пользователя""" - async with self.pool.acquire() as connection: - row = await connection.fetchrow( - "INSERT INTO users (email, name, age) VALUES ($1, $2, $3) RETURNING id", - email, name, age - ) - return row['id'] - - async def get_user_by_id(self, user_id: int) -> Optional[dict]: - """Получение пользователя по ID""" - async with self.pool.acquire() as connection: - row = await connection.fetchrow( - "SELECT id, email, name, age, created_at FROM users WHERE id = $1", - user_id - ) - return dict(row) if row else None - - async def update_user_age(self, user_id: int, new_age: int) -> bool: - """Обновление возраста пользователя""" - async with self.pool.acquire() as connection: - result = await connection.execute( - "UPDATE users SET age = $1 WHERE id = $2", - new_age, user_id - ) - return result.split()[-1] == '1' - - async def get_users_with_orders(self) -> List[dict]: - """Получение пользователей с количеством их заказов (JOIN запрос)""" - async with self.pool.acquire() as connection: - rows = await connection.fetch(""" - SELECT - u.id, u.name, u.email, - COUNT(o.id) as order_count, - COALESCE(SUM(o.total_price), 0) as total_spent - FROM users u - LEFT JOIN orders o ON u.id = o.user_id - GROUP BY u.id, u.name, u.email - ORDER BY total_spent DESC - """) - return [dict(row) for row in rows] diff --git a/lecture4/1_raw_asyncpg/requirements.txt b/lecture4/1_raw_asyncpg/requirements.txt deleted file mode 100644 index 9d47fee1..00000000 --- a/lecture4/1_raw_asyncpg/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -asyncpg==0.29.0 -python-dotenv==1.0.0 diff --git a/lecture4/2_active_record/main.py b/lecture4/2_active_record/main.py deleted file mode 100644 index 6619be91..00000000 --- a/lecture4/2_active_record/main.py +++ /dev/null @@ -1,74 +0,0 @@ -from typing import List, Optional -from datetime import datetime -from sqlmodel import SQLModel, Field, Session, select - - -# === ActiveRecord модели === - -class User(SQLModel, table=True): - __tablename__ = "users" - - id: Optional[int] = Field(default=None, primary_key=True) - email: str = Field(unique=True, index=True, max_length=255) - name: str = Field(max_length=255) - age: int = Field(ge=0) - created_at: Optional[datetime] = Field(default_factory=datetime.utcnow) - updated_at: Optional[datetime] = Field(default_factory=datetime.utcnow) - - # === ActiveRecord методы === - - @classmethod - def create(cls, session: Session, email: str, name: str, age: int) -> "User": - """Создание нового пользователя""" - user = cls(email=email, name=name, age=age) - session.add(user) - session.commit() - session.refresh(user) - return user - - @classmethod - def find_by_id(cls, session: Session, user_id: int) -> Optional["User"]: - """Поиск пользователя по ID""" - return session.get(cls, user_id) - - @classmethod - def find_by_email(cls, session: Session, email: str) -> Optional["User"]: - """Поиск пользователя по email""" - statement = select(cls).where(cls.email == email) - return session.exec(statement).first() - - @classmethod - def get_all_with_stats(cls, session: Session) -> List[dict]: - """Получение всех пользователей со статистикой заказов""" - statement = select(cls).order_by(cls.created_at) - users = session.exec(statement).all() - - result = [] - for user in users: - result.append({ - "id": user.id, - "name": user.name, - "email": user.email, - "age": user.age, - "order_count": 0 - }) - return result - - def update_age(self, session: Session, new_age: int) -> "User": - """Обновление возраста пользователя""" - self.age = new_age - self.updated_at = datetime.utcnow() - session.add(self) - session.commit() - session.refresh(self) - return self - - def to_dict(self) -> dict: - """Преобразование в словарь для вывода""" - return { - "id": self.id, - "email": self.email, - "name": self.name, - "age": self.age, - "created_at": self.created_at - } diff --git a/lecture4/2_active_record/requirements.txt b/lecture4/2_active_record/requirements.txt deleted file mode 100644 index 0265b8a7..00000000 --- a/lecture4/2_active_record/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -sqlmodel==0.0.14 -psycopg2-binary==2.9.9 -python-dotenv==1.0.0 diff --git a/lecture4/3_data_mapper_sqlalchemy/main.py b/lecture4/3_data_mapper_sqlalchemy/main.py deleted file mode 100644 index f3b99486..00000000 --- a/lecture4/3_data_mapper_sqlalchemy/main.py +++ /dev/null @@ -1,156 +0,0 @@ -from typing import List, Optional -from dataclasses import dataclass -from abc import ABC, abstractmethod - -from sqlalchemy import Column, Integer, String, DateTime -from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import Session -from sqlalchemy.sql import func - - -# === Доменные модели (без привязки к БД) === - -@dataclass -class User: - """Доменная модель пользователя""" - id: Optional[int] = None - email: str = "" - name: str = "" - age: int = 0 - - -# === SQLAlchemy модели (для мапинга с БД) === - -Base = declarative_base() - - -class UserOrm(Base): - __tablename__ = 'users' - - id = Column(Integer, primary_key=True) - email = Column(String(255), unique=True, nullable=False) - name = Column(String(255), nullable=False) - age = Column(Integer, nullable=False) - created_at = Column(DateTime, default=func.now()) - updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) - - -# === Мапперы (преобразование между доменными моделями и ORM) === - -class UserMapper: - """Маппер для преобразования между User и UserOrm""" - - @staticmethod - def to_domain(orm_user: UserOrm) -> User: - """Преобразование ORM модели в доменную""" - return User( - id=orm_user.id, - email=orm_user.email, - name=orm_user.name, - age=orm_user.age - ) - - @staticmethod - def to_orm( - domain_user: User, - orm_user: Optional[UserOrm] = None, - ) -> UserOrm: - """Преобразование доменной модели в ORM""" - if orm_user is None: - orm_user = UserOrm() - - orm_user.email = domain_user.email - orm_user.name = domain_user.name - orm_user.age = domain_user.age - - return orm_user - - -# === Абстрактные интерфейсы репозиториев === - -class UserRepositoryInterface(ABC): - """Интерфейс репозитория пользователей""" - - @abstractmethod - def create(self, user: User) -> User: - pass - - @abstractmethod - def find_by_id(self, user_id: int) -> Optional[User]: - pass - - @abstractmethod - def find_by_email(self, email: str) -> Optional[User]: - pass - - @abstractmethod - def get_all(self) -> List[User]: - pass - - @abstractmethod - def update(self, user: User) -> User: - pass - - -# === Конкретные реализации репозиториев === - -class SqlAlchemyUserRepository(UserRepositoryInterface): - """SQLAlchemy реализация репозитория пользователей""" - - def __init__(self, session: Session): - self.session = session - - def create(self, user: User) -> User: - orm_user = UserMapper.to_orm(user) - self.session.add(orm_user) - self.session.flush() # Получаем ID без коммита - return UserMapper.to_domain(orm_user) - - def find_by_id(self, user_id: int) -> Optional[User]: - orm_user = self.session.query(UserOrm).filter_by(id=user_id).first() - return UserMapper.to_domain(orm_user) if orm_user else None - - def find_by_email(self, email: str) -> Optional[User]: - orm_user = self.session.query(UserOrm).filter_by(email=email).first() - return UserMapper.to_domain(orm_user) if orm_user else None - - def get_all(self) -> List[User]: - orm_users = self.session.query(UserOrm).order_by(UserOrm.created_at).all() - return [UserMapper.to_domain(orm_user) for orm_user in orm_users] - - def update(self, user: User) -> User: - orm_user = self.session.query(UserOrm).filter_by(id=user.id).first() - if not orm_user: - raise ValueError(f"User with id {user.id} not found") - - UserMapper.to_orm(user, orm_user) - self.session.flush() - return UserMapper.to_domain(orm_user) - - -# === Сервисы для бизнес-логики === - -class UserService: - """Сервис для работы с пользователями""" - - def __init__(self, user_repo: UserRepositoryInterface): - self.user_repo = user_repo - - def create_user(self, email: str, name: str, age: int) -> User: - """Создание нового пользователя с валидацией""" - existing_user = self.user_repo.find_by_email(email) - if existing_user: - raise ValueError(f"User with email {email} already exists") - - if age < 0: - raise ValueError("Age cannot be negative") - - user = User(email=email, name=name, age=age) - return self.user_repo.create(user) - - def get_user_with_validation(self, user_id: int) -> User: - """Получение пользователя с проверкой существования""" - user = self.user_repo.find_by_id(user_id) - if not user: - raise ValueError(f"User with id {user_id} not found") - return user diff --git a/lecture4/3_data_mapper_sqlalchemy/requirements.txt b/lecture4/3_data_mapper_sqlalchemy/requirements.txt deleted file mode 100644 index e00142d2..00000000 --- a/lecture4/3_data_mapper_sqlalchemy/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ -sqlalchemy==2.0.25 -psycopg2-binary==2.9.9 -python-dotenv==1.0.0 diff --git a/lecture4/4_edgedb/.gitignore b/lecture4/4_edgedb/.gitignore deleted file mode 100644 index 438992eb..00000000 --- a/lecture4/4_edgedb/.gitignore +++ /dev/null @@ -1,112 +0,0 @@ -# Сгенерированные EdgeDB файлы -generated/ -*.egg-info/ - -# Python -__pycache__/ -*.py[cod] -*$py.class -*.so -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# celery beat schedule file -celerybeat-schedule - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ diff --git a/lecture4/4_edgedb/README.md b/lecture4/4_edgedb/README.md deleted file mode 100644 index 06796455..00000000 --- a/lecture4/4_edgedb/README.md +++ /dev/null @@ -1,76 +0,0 @@ -# Пример работы с EdgeDB - -Этот пример демонстрирует работу с **EdgeDB** (Gel) - -## Что такое EdgeDB? - -EdgeDB (Теперь называется Gel) - это база данных, построенная поверх PostgreSQL, которая предоставляет: - -- **EdgeQL** - мощный язык запросов, похожий на GraphQL -- **Строгую типизацию** - схема определяется в `.gel` файлах -- **Автоматическую генерацию кода** - Python типы из схемы -- **Встроенные миграции** - автоматическое управление схемой -- **Объектно-ориентированные запросы** - работа с объектами, а не таблицами - -## Установка и настройка: - -### 1. Установка EdgeDB CLI -```bash -# macOS -curl --proto '=https' --tlsv1.2 -sSf https://sh.edgedb.com | sh - -# Ubuntu/Debian -curl https://packages.edgedb.com/keys/edgedb.asc | sudo apt-key add - -echo "deb https://packages.edgedb.com/apt $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/edgedb.list -sudo apt update && sudo apt install edgedb-cli -``` - -### 2. Инициализация проекта -```bash -# Переход в папку с EdgeDB -cd 4_edgedb - -# Инициализация проекта EdgeDB -edgedb project init - -# Создание и применение миграций -edgedb migration create -edgedb migrate - -# Установка Python зависимостей -pip install -r requirements.txt -``` - -### 3. Генерация типизированного Python кода -```bash -# Генерация асинхронных функций из queries/*.edgeql файлов -edgedb-py --target async --dir queries --out-dir generated - -# Альтернативно - синхронные функции -edgedb-py --target sync --dir queries --out-dir generated - -# Генерация в конкретный файл (все запросы в одном файле) -edgedb-py --target async --dir queries --file generated_queries.py -``` - -Вам сгенерируются Python функции, которые вы сможете вызывать из вашего кода. Они выполнят ваш запрос и вернут сразу Dataclass с результатом запроса, что довольно удобно. - - -### Основные команды: - -```bash -# Генерация асинхронных функций (рекомендуется) -edgedb-py --target async --dir queries --out-dir generated - -# Генерация синхронных функций -edgedb-py --target sync --dir queries --out-dir generated - -# Генерация в один файл -edgedb-py --target async --dir queries --file all_queries.py - -# Генерация с опциями -edgedb-py --target async \ - --dir queries \ - --out-dir generated \ - --no-skip-pyi-files # Создавать .pyi файлы для type hints -``` diff --git a/lecture4/4_edgedb/dbschema/default.gel b/lecture4/4_edgedb/dbschema/default.gel deleted file mode 100644 index 7a76af78..00000000 --- a/lecture4/4_edgedb/dbschema/default.gel +++ /dev/null @@ -1,55 +0,0 @@ -# Схема базы данных EdgeDB - -# Тип для пользователей -type default::User { - required email: str { - constraint exclusive; - }; - required name: str; - required age: int32 { - constraint min_value(0); - }; - created_at: datetime { - default := datetime_current(); - }; - - # Обратная связь с заказами - multi orders := .$product_id diff --git a/lecture4/4_edgedb/queries/create_order.edgeql b/lecture4/4_edgedb/queries/create_order.edgeql deleted file mode 100644 index 6a42ae02..00000000 --- a/lecture4/4_edgedb/queries/create_order.edgeql +++ /dev/null @@ -1,11 +0,0 @@ -# Создание заказа с автоматическим вычислением стоимости -WITH - user := (SELECT User FILTER .id = $user_id), - product := (SELECT Product FILTER .id = $product_id) -INSERT Order { - user := user, - product := product, - quantity := $quantity, - total_price := product.price * $quantity, - status := 'pending' -} diff --git a/lecture4/4_edgedb/queries/create_product.edgeql b/lecture4/4_edgedb/queries/create_product.edgeql deleted file mode 100644 index f1304e04..00000000 --- a/lecture4/4_edgedb/queries/create_product.edgeql +++ /dev/null @@ -1,7 +0,0 @@ -# Создание продукта -INSERT Product { - name := $name, - price := $price, - description := $description, - in_stock := $in_stock -} diff --git a/lecture4/4_edgedb/queries/create_user.edgeql b/lecture4/4_edgedb/queries/create_user.edgeql deleted file mode 100644 index 50b23aeb..00000000 --- a/lecture4/4_edgedb/queries/create_user.edgeql +++ /dev/null @@ -1,6 +0,0 @@ -# Создание пользователя -INSERT User { - email := $email, - name := $name, - age := $age -} diff --git a/lecture4/4_edgedb/queries/delete_user.edgeql b/lecture4/4_edgedb/queries/delete_user.edgeql deleted file mode 100644 index 884e4d98..00000000 --- a/lecture4/4_edgedb/queries/delete_user.edgeql +++ /dev/null @@ -1,3 +0,0 @@ -# Удаление пользователя -DELETE User -FILTER .id = $user_id diff --git a/lecture4/4_edgedb/requirements.txt b/lecture4/4_edgedb/requirements.txt deleted file mode 100644 index 41b198cd..00000000 --- a/lecture4/4_edgedb/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -edgedb==2.1.0 -python-dotenv==1.0.0 diff --git a/lecture4/Dockerfile b/lecture4/Dockerfile new file mode 100644 index 00000000..5a1efb96 --- /dev/null +++ b/lecture4/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.11-slim +RUN apt-get update && apt-get install -y gcc libpq-dev && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY requirements.txt . +RUN pip install -r requirements.txt +COPY . . +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/lecture4/README.md b/lecture4/README.md index 26822ef7..65e8e90a 100644 --- a/lecture4/README.md +++ b/lecture4/README.md @@ -12,4 +12,76 @@ показать что нет non-repeatable read при repeatable read показать phantom reads при repeatable read показать что нет phantom reads при serializable -*Тут зависит от того какую БД вы выбрали, разные БД могут поддерживать разные уровни изоляции \ No newline at end of file +*Тут зависит от того какую БД вы выбрали, разные БД могут поддерживать разные уровни изоляции + +## Итог +БД в docker-compose.yml — добавлен сервис db: postgres:16, volume, healthcheck. + +API переведён на БД — FastAPI + SQLAlchemy 2.x, DSN postgresql+psycopg://shop:shop@db:5432/shop, таблицы items, carts, cart_items. + +Скрипты аномалий — в scripts/: + +dirty_read_pg.py — показывает отсутствие dirty read на READ UNCOMMITTED и READ COMMITTED в Postgres (UNCOMMITTED ≡ COMMITTED). + +nonrepeatable_pg.py — non-repeatable read на READ COMMITTED, отсутствует на REPEATABLE READ. + +phantom_pg.py — phantom на READ COMMITTED, отсутствует на SERIALIZABLE внутри одной транзакции (SSI). +Индекс для предикат-локов: idx_items_price_not_deleted на items(price) WHERE deleted=false. + +## Развертывание +docker compose build --no-cache +docker compose up -d + +## Запуск проверок +### dirty read (в PG отсутствует в принципе) +docker compose exec -e PG_DSN="postgresql://shop:shop@db:5432/shop" api python scripts/dirty_read_pg.py + +### non-repeatable read +docker compose exec -e PG_DSN="postgresql://shop:shop@db:5432/shop" api python scripts/nonrepeatable_pg.py + +### phantom read и SERIALIZABLE +docker compose exec -e PG_DSN="postgresql://shop:shop@db:5432/shop" api python scripts/phantom_pg.py + +## Ожидаемые логи + +```python +dirty_read_pg.py + +T1 updated A=999 not committed +T2 sees (no dirty read): 100.00 +T1 rolled back + + +Комментарий: в PostgreSQL READ UNCOMMITTED ≡ READ COMMITTED, dirty read недостижим.~~ +``` +```python +nonrepeatable_pg.py + +T1 RC iso: read committed +T1 first B: 200.00 +T2 RC iso: read committed +T2 committed update B +T1 second B (changed): 201.00 + +T1 RR iso: repeatable read +T1 RR first: 200.00 +T2 RC iso: read committed +T2 updated B under RC +T1 RR second (same): 200.00 +``` +```python +phantom_pg.py + +T1 RC iso: read committed +T1 RC count1: 1 +T2 RC iso: read committed +T2 inserted PH=1000 +T1 RC count2 (phantom expected): 2 + +T1 SER iso: serializable +T1 SER count1: 1 +T2 SER iso: serializable +T2 SER committed +T1 SER count2 (same): 1 +T1 SER committed +``` \ No newline at end of file diff --git a/lecture4/database.py b/lecture4/database.py new file mode 100644 index 00000000..19bf0111 --- /dev/null +++ b/lecture4/database.py @@ -0,0 +1,14 @@ +import os +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +DATABASE_URL = os.getenv("DATABASE_URL") +engine = create_engine(DATABASE_URL, pool_pre_ping=True, future=True) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/lecture4/docker-compose.yml b/lecture4/docker-compose.yml index 24a07eee..72fc541a 100644 --- a/lecture4/docker-compose.yml +++ b/lecture4/docker-compose.yml @@ -1,34 +1,33 @@ -version: '3.8' +version: "3.9" services: - postgres: - image: postgres:15 - container_name: hw4_postgres + db: + image: postgres:16 + container_name: shop-pg environment: - POSTGRES_DB: hw4_db - POSTGRES_USER: postgres - POSTGRES_PASSWORD: password - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data - - ./migrations/init.sql:/docker-entrypoint-initdb.d/init.sql + - POSTGRES_USER=shop + - POSTGRES_PASSWORD=shop + - POSTGRES_DB=shop + ports: ["5432:5432"] healthcheck: - test: ["CMD-SHELL", "pg_isready -U postgres"] + test: ["CMD-SHELL", "pg_isready -U shop -d shop"] interval: 5s - timeout: 5s - retries: 5 + timeout: 3s + retries: 50 + volumes: + - pg-data:/var/lib/postgresql/data + - ./init.sql:/docker-entrypoint-initdb.d/00_init.sql:ro - edgedb: - image: edgedb/edgedb:5 - container_name: hw4_edgedb + api: + build: + context: . + dockerfile: Dockerfile environment: - EDGEDB_SERVER_SECURITY: insecure_dev_mode - ports: - - "5656:5656" - volumes: - - edgedb_data:/var/lib/edgedb/data + - DATABASE_URL=postgresql+psycopg://shop:shop@db:5432/shop + depends_on: + db: + condition: service_healthy + ports: ["8000:8000"] volumes: - postgres_data: - edgedb_data: + pg-data: diff --git a/lecture4/init.sql b/lecture4/init.sql new file mode 100644 index 00000000..ddadf910 --- /dev/null +++ b/lecture4/init.sql @@ -0,0 +1,25 @@ +-- схемы +CREATE TABLE IF NOT EXISTS items ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + price NUMERIC(12,2) NOT NULL, + deleted BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE IF NOT EXISTS carts ( + id SERIAL PRIMARY KEY +); + +CREATE TABLE IF NOT EXISTS cart_items ( + cart_id INT NOT NULL REFERENCES carts(id) ON DELETE CASCADE, + item_id INT NOT NULL REFERENCES items(id), + quantity INT NOT NULL CHECK (quantity > 0), + PRIMARY KEY (cart_id, item_id) +); + +-- начальные данные для скриптов +TRUNCATE cart_items, carts, items RESTART IDENTITY; +INSERT INTO items(name, price, deleted) VALUES ('A', 100, FALSE), ('B', 200, FALSE); + +CREATE INDEX IF NOT EXISTS idx_items_price_not_deleted + ON items(price) WHERE deleted = FALSE; diff --git a/lecture4/main.py b/lecture4/main.py new file mode 100644 index 00000000..7d1e149b --- /dev/null +++ b/lecture4/main.py @@ -0,0 +1,158 @@ +from http import HTTPStatus +from typing import Optional, List +from fastapi import FastAPI, HTTPException, Query, Response, Depends +from pydantic import BaseModel, NonNegativeInt, PositiveInt, PositiveFloat, ConfigDict +from sqlalchemy import select, func +from sqlalchemy.orm import Session + +from database import get_db +from models import ItemORM, CartORM, CartItemORM + +app = FastAPI(title="Shop API") + +class Item(BaseModel): + id: int + name: str + price: float + deleted: bool = False + +class ItemCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str + price: PositiveFloat + +class ItemPut(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str + price: PositiveFloat + +class ItemPatch(BaseModel): + model_config = ConfigDict(extra="forbid") + name: Optional[str] = None + price: Optional[PositiveFloat] = None + +def _cart_total_price(db: Session, cid: int) -> float: + q = ( + select(func.coalesce(func.sum(ItemORM.price * CartItemORM.quantity), 0)) + .join(CartItemORM, CartItemORM.item_id == ItemORM.id) + .where(CartItemORM.cart_id == cid, ItemORM.deleted == False) # noqa: E712 + ) + return float(db.execute(q).scalar_one()) + +def _cart_items_repr(db: Session, cid: int) -> List[dict]: + q = select(CartItemORM.item_id, CartItemORM.quantity).where(CartItemORM.cart_id == cid) + return [{"id": r.item_id, "quantity": r.quantity} for r in db.execute(q).all()] + +@app.post("/item", status_code=HTTPStatus.CREATED) +def create_item(body: ItemCreate, db: Session = Depends(get_db)): + row = ItemORM(name=body.name, price=float(body.price)) + db.add(row); db.commit(); db.refresh(row) + return {"id": row.id, "name": row.name, "price": float(row.price), "deleted": row.deleted} + +@app.get("/item/{id}") +def get_item(id: int, db: Session = Depends(get_db)): + row = db.get(ItemORM, id) + if row is None or row.deleted: + raise HTTPException(HTTPStatus.NOT_FOUND) + return {"id": row.id, "name": row.name, "price": float(row.price), "deleted": row.deleted} + +@app.get("/item") +def list_items( + offset: NonNegativeInt = 0, + limit: Optional[PositiveInt] = None, + min_price: Optional[float] = Query(default=None, ge=0.0), + max_price: Optional[float] = Query(default=None, ge=0.0), + show_deleted: bool = False, + db: Session = Depends(get_db), +): + q = select(ItemORM) + if min_price is not None: q = q.where(ItemORM.price >= float(min_price)) + if max_price is not None: q = q.where(ItemORM.price <= float(max_price)) + q = q.order_by(ItemORM.id).offset(offset) + if limit is not None: q = q.limit(limit) + out = [] + for row in db.execute(q).scalars(): + if show_deleted or not row.deleted: + out.append({"id": row.id, "name": row.name, "price": float(row.price), "deleted": row.deleted}) + return out + +@app.put("/item/{id}") +def put_item(id: int, body: ItemPut, db: Session = Depends(get_db)): + row = db.get(ItemORM, id) + if row is None or row.deleted: + raise HTTPException(HTTPStatus.NOT_MODIFIED) + row.name = body.name + row.price = float(body.price) + db.commit(); db.refresh(row) + return {"id": row.id, "name": row.name, "price": float(row.price), "deleted": row.deleted} + +@app.patch("/item/{id}") +def patch_item(id: int, body: ItemPatch, db: Session = Depends(get_db)): + row = db.get(ItemORM, id) + if row is None or row.deleted: + raise HTTPException(HTTPStatus.NOT_MODIFIED) + updates = body.model_dump(exclude_unset=True) + if "name" in updates: row.name = updates["name"] + if "price" in updates: row.price = float(updates["price"]) + db.commit(); db.refresh(row) + return {"id": row.id, "name": row.name, "price": float(row.price), "deleted": row.deleted} + +@app.delete("/item/{id}") +def delete_item(id: int, db: Session = Depends(get_db)): + row = db.get(ItemORM, id) + if row is None: return Response("") + if not row.deleted: + row.deleted = True; db.commit() + return Response("") + +@app.post("/cart", status_code=HTTPStatus.CREATED) +def create_cart(response: Response, db: Session = Depends(get_db)): + cart = CartORM(); db.add(cart); db.commit(); db.refresh(cart) + response.headers["Location"] = f"/cart/{cart.id}" + return {"id": cart.id} + +@app.get("/cart/{id}") +def get_cart(id: int, db: Session = Depends(get_db)): + cart = db.get(CartORM, id) + if cart is None: raise HTTPException(HTTPStatus.NOT_FOUND) + return {"id": id, "items": _cart_items_repr(db, id), "price": _cart_total_price(db, id)} + +@app.post("/cart/{id}/add/{item_id}") +def add_item_to_cart(id: int, item_id: int, db: Session = Depends(get_db)): + if db.get(CartORM, id) is None: raise HTTPException(HTTPStatus.NOT_FOUND) + # main.py (add_item_to_cart) + ci = db.get(CartItemORM, (id, item_id)) # вместо dict + if ci is None: + ci = CartItemORM(cart_id=id, item_id=item_id, quantity=1); db.add(ci) + else: + ci.quantity += 1 + db.commit() + return {"id": id, "items": _cart_items_repr(db, id), "price": _cart_total_price(db, id)} + +@app.get("/cart") +def list_carts( + offset: NonNegativeInt = 0, + limit: Optional[PositiveInt] = None, + min_price: Optional[float] = Query(default=None, ge=0.0), + max_price: Optional[float] = Query(default=None, ge=0.0), + min_quantity: Optional[NonNegativeInt] = None, + max_quantity: Optional[NonNegativeInt] = None, + db: Session = Depends(get_db), +): + ids = [r for r in db.execute(select(CartORM.id).order_by(CartORM.id)).scalars().all()] + filtered = [] + for cid in ids: + price = _cart_total_price(db, cid) + if min_price is not None and price < float(min_price): continue + if max_price is not None and price > float(max_price): continue + filtered.append(cid) + if min_quantity is not None or max_quantity is not None: + agg = 0; constrained = [] + for cid in filtered: + q = select(func.coalesce(func.sum(CartItemORM.quantity), 0)).where(CartItemORM.cart_id == cid) + cart_qty = int(db.execute(q).scalar_one()) + if max_quantity is not None and agg + cart_qty > max_quantity: break + constrained.append(cid); agg += cart_qty + filtered = constrained + sliced = filtered[offset:] if limit is None else filtered[offset: offset+limit] + return [{"id": cid, "items": _cart_items_repr(db, cid), "price": _cart_total_price(db, cid)} for cid in sliced] diff --git a/lecture4/models.py b/lecture4/models.py new file mode 100644 index 00000000..da796e2e --- /dev/null +++ b/lecture4/models.py @@ -0,0 +1,21 @@ +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship +from sqlalchemy import Integer, String, Numeric, Boolean, ForeignKey + +class Base(DeclarativeBase): pass + +class ItemORM(Base): + __tablename__ = "items" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String) + price: Mapped[float] = mapped_column(Numeric(12,2)) + deleted: Mapped[bool] = mapped_column(Boolean, default=False) + +class CartORM(Base): + __tablename__ = "carts" + id: Mapped[int] = mapped_column(Integer, primary_key=True) + +class CartItemORM(Base): + __tablename__ = "cart_items" + cart_id: Mapped[int] = mapped_column(ForeignKey("carts.id"), primary_key=True) + item_id: Mapped[int] = mapped_column(ForeignKey("items.id"), primary_key=True) + quantity: Mapped[int] = mapped_column(Integer) diff --git a/lecture4/requirements.txt b/lecture4/requirements.txt new file mode 100644 index 00000000..ec5c8946 --- /dev/null +++ b/lecture4/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +SQLAlchemy==2.0.34 +psycopg[binary]==3.2.1 +pydantic==2.9.2 diff --git a/lecture4/scripts/dirty_read_pg.py b/lecture4/scripts/dirty_read_pg.py new file mode 100644 index 00000000..b0626d61 --- /dev/null +++ b/lecture4/scripts/dirty_read_pg.py @@ -0,0 +1,25 @@ +from util import conn, begin, reset_items +import threading, time + +reset_items() + +def t1(): + c = conn(); cur = c.cursor() + begin(cur, "READ COMMITTED") + cur.execute("UPDATE items SET price = 999 WHERE name='A'") + print("T1 updated A=999 not committed") + time.sleep(3) + cur.execute("ROLLBACK"); print("T1 rolled back") + cur.close(); c.close() + +def t2(): + c = conn(); cur = c.cursor() + begin(cur, "READ UNCOMMITTED") # в PG это RC + time.sleep(1) + cur.execute("SELECT price FROM items WHERE name='A'") + print("T2 sees (no dirty read):", cur.fetchone()[0]) + cur.execute("COMMIT"); cur.close(); c.close() + +if __name__ == "__main__": + th1 = threading.Thread(target=t1); th2 = threading.Thread(target=t2) + th1.start(); th2.start(); th1.join(); th2.join() diff --git a/lecture4/scripts/nonrepeatable_pg.py b/lecture4/scripts/nonrepeatable_pg.py new file mode 100644 index 00000000..1ed13a3e --- /dev/null +++ b/lecture4/scripts/nonrepeatable_pg.py @@ -0,0 +1,52 @@ +from util import conn, begin, reset_items +import threading, time + +def show(cur, tag): + cur.execute("SHOW TRANSACTION ISOLATION LEVEL") + print(f"{tag} iso:", cur.fetchone()[0]) + +reset_items() + +def t1_rc(): + c = conn(); cur = c.cursor() + begin(cur, "READ COMMITTED"); show(cur, "T1 RC") + cur.execute("SELECT price FROM items WHERE name='B'"); r1 = cur.fetchone()[0] + print("T1 first B:", r1) + time.sleep(3) + cur.execute("SELECT price FROM items WHERE name='B'"); r2 = cur.fetchone()[0] + print("T1 second B (changed):", r2) + cur.execute("COMMIT"); cur.close(); c.close() + +def t2_upd(): + c = conn(); cur = c.cursor() + time.sleep(1) + begin(cur, "READ COMMITTED"); show(cur, "T2 RC") + cur.execute("UPDATE items SET price = price + 1 WHERE name='B'") + cur.execute("COMMIT"); print("T2 committed update B") + cur.close(); c.close() + +th1 = threading.Thread(target=t1_rc); th2 = threading.Thread(target=t2_upd) +th1.start(); th2.start(); th1.join(); th2.join() + +reset_items() + +def t1_rr(): + c = conn(); cur = c.cursor() + begin(cur, "REPEATABLE READ"); show(cur, "T1 RR") + cur.execute("SELECT price FROM items WHERE name='B'"); r1 = cur.fetchone()[0] + print("T1 RR first:", r1) + time.sleep(3) + cur.execute("SELECT price FROM items WHERE name='B'"); r2 = cur.fetchone()[0] + print("T1 RR second (same):", r2) + cur.execute("COMMIT"); cur.close(); c.close() + +def t2_rr(): + c = conn(); cur = c.cursor() + time.sleep(1) + begin(cur, "READ COMMITTED"); show(cur, "T2 RC") + cur.execute("UPDATE items SET price = price + 1 WHERE name='B'") + cur.execute("COMMIT"); print("T2 updated B under RC") + cur.close(); c.close() + +th1 = threading.Thread(target=t1_rr); th2 = threading.Thread(target=t2_rr) +th1.start(); th2.start(); th1.join(); th2.join() diff --git a/lecture4/scripts/phantom_pg.py b/lecture4/scripts/phantom_pg.py new file mode 100644 index 00000000..d9e840aa --- /dev/null +++ b/lecture4/scripts/phantom_pg.py @@ -0,0 +1,64 @@ +from util import conn, begin, reset_items +import threading, time +import psycopg + +def show(cur, tag): + cur.execute("SHOW TRANSACTION ISOLATION LEVEL") + print(f"{tag} iso:", cur.fetchone()[0]) + +reset_items() + +def t1_rc(): + c = conn(); cur = c.cursor() + begin(cur, "READ COMMITTED"); show(cur, "T1 RC") + cur.execute("SELECT COUNT(*) FROM items WHERE price >= 150 AND deleted = FALSE") + n1 = cur.fetchone()[0]; print("T1 RC count1:", n1) + time.sleep(3) + cur.execute("SELECT COUNT(*) FROM items WHERE price >= 150 AND deleted = FALSE") + n2 = cur.fetchone()[0]; print("T1 RC count2 (phantom expected):", n2) + cur.execute("COMMIT"); cur.close(); c.close() + +def t2_ins(): + c = conn(); cur = c.cursor() + time.sleep(1) + begin(cur, "READ COMMITTED"); show(cur, "T2 RC") + cur.execute("INSERT INTO items(name,price,deleted) VALUES ('PH', 1000, FALSE)") + cur.execute("COMMIT"); print("T2 inserted PH=1000") + cur.close(); c.close() + +th1 = threading.Thread(target=t1_rc); th2 = threading.Thread(target=t2_ins) +th1.start(); th2.start(); th1.join(); th2.join() + +reset_items() + +def t1_ser(): + c = conn(); cur = c.cursor() + try: + begin(cur, "SERIALIZABLE"); show(cur, "T1 SER") + cur.execute("SELECT COUNT(*) FROM items WHERE price >= 150 AND deleted = FALSE") + n1 = cur.fetchone()[0]; print("T1 SER count1:", n1) + time.sleep(3) + cur.execute("SELECT COUNT(*) FROM items WHERE price >= 150 AND deleted = FALSE") + n2 = cur.fetchone()[0]; print("T1 SER count2 (same):", n2) + cur.execute("COMMIT"); print("T1 SER committed") + except psycopg.errors.SerializationFailure as e: + print("T1 SER serialization failure:", e.__class__.__name__); cur.execute("ROLLBACK") + finally: + cur.close(); c.close() + +def t2_ser(): + c = conn(); cur = c.cursor() + try: + time.sleep(1) + begin(cur, "SERIALIZABLE"); show(cur, "T2 SER") + cur.execute("INSERT INTO items(name,price,deleted) VALUES ('PH2', 500, FALSE)") + cur.execute("COMMIT"); print("T2 SER committed") + except psycopg.errors.SerializationFailure as e: + print("T2 SER serialization failure:", e.__class__.__name__) + try: cur.execute("ROLLBACK") + except: pass + finally: + cur.close(); c.close() + +th1 = threading.Thread(target=t1_ser); th2 = threading.Thread(target=t2_ser) +th1.start(); th2.start(); th1.join(); th2.join() diff --git a/lecture4/scripts/util.py b/lecture4/scripts/util.py new file mode 100644 index 00000000..2a207aba --- /dev/null +++ b/lecture4/scripts/util.py @@ -0,0 +1,14 @@ +import os, psycopg +DSN = os.getenv("PG_DSN", "postgresql://shop:shop@localhost:5432/shop") + +def conn(): + return psycopg.connect(DSN, autocommit=False) + +def begin(cur, iso: str): + # iso: "READ COMMITTED" | "REPEATABLE READ" | "SERIALIZABLE" + cur.execute(f"BEGIN ISOLATION LEVEL {iso}") + +def reset_items(): + with psycopg.connect(DSN, autocommit=True) as c, c.cursor() as cur: + cur.execute("TRUNCATE cart_items, carts, items RESTART IDENTITY") + cur.execute("INSERT INTO items(name,price,deleted) VALUES ('A',100,false),('B',200,false)") From e93a554f34938790decc87376427decb2d6a69af Mon Sep 17 00:00:00 2001 From: safroalex Date: Tue, 21 Oct 2025 16:37:11 +0300 Subject: [PATCH 4/5] add hw5 --- .github/workflows/lecture4-ci.yml | 63 +++++++++++++++++++++++++++++++ lecture4/Dockerfile | 1 + lecture4/pytest.ini | 3 ++ lecture4/requirements.txt | 3 ++ lecture4/tests/conftest.py | 53 ++++++++++++++++++++++++++ lecture4/tests/test_carts.py | 33 ++++++++++++++++ lecture4/tests/test_items.py | 49 ++++++++++++++++++++++++ 7 files changed, 205 insertions(+) create mode 100644 .github/workflows/lecture4-ci.yml create mode 100644 lecture4/pytest.ini create mode 100644 lecture4/tests/conftest.py create mode 100644 lecture4/tests/test_carts.py create mode 100644 lecture4/tests/test_items.py diff --git a/.github/workflows/lecture4-ci.yml b/.github/workflows/lecture4-ci.yml new file mode 100644 index 00000000..74cbf515 --- /dev/null +++ b/.github/workflows/lecture4-ci.yml @@ -0,0 +1,63 @@ +name: lecture4-tests + +on: + push: + paths: + - 'lecture4/**' + - '.github/workflows/lecture4-ci.yml' + pull_request: + paths: + - 'lecture4/**' + - '.github/workflows/lecture4-ci.yml' + +jobs: + test: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: lecture4 # важное: все команды из lecture4/ + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: shop + POSTGRES_PASSWORD: shop + POSTGRES_DB: shop + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U shop -d shop" + --health-interval=5s + --health-timeout=3s + --health-retries=50 + + env: + # тесты ходят в отдельную БД + DATABASE_URL: postgresql+psycopg://shop:shop@localhost:5432/shop_test + PYTHONPATH: ${{ github.workspace }}/lecture4 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install deps + run: | + python -m pip install -U pip + pip install -r requirements.txt + + - name: Wait for Postgres and create test DB + env: + PGPASSWORD: shop + run: | + until psql -h localhost -U shop -d shop -c "SELECT 1" >/dev/null 2>&1; do sleep 1; done + psql -h localhost -U shop -d postgres -c "CREATE DATABASE shop_test" || true + psql -h localhost -U shop -d shop_test -f init.sql + + - name: Run tests with coverage ≥95% + run: pytest diff --git a/lecture4/Dockerfile b/lecture4/Dockerfile index 5a1efb96..860386ff 100644 --- a/lecture4/Dockerfile +++ b/lecture4/Dockerfile @@ -1,4 +1,5 @@ FROM python:3.11-slim +ENV PYTHONPATH=/app RUN apt-get update && apt-get install -y gcc libpq-dev && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY requirements.txt . diff --git a/lecture4/pytest.ini b/lecture4/pytest.ini new file mode 100644 index 00000000..8b362d95 --- /dev/null +++ b/lecture4/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +addopts = -q --cov=main --cov=models --cov=database --cov-report=term-missing --cov-fail-under=95 +testpaths = tests diff --git a/lecture4/requirements.txt b/lecture4/requirements.txt index ec5c8946..404dd26a 100644 --- a/lecture4/requirements.txt +++ b/lecture4/requirements.txt @@ -3,3 +3,6 @@ uvicorn[standard]==0.30.6 SQLAlchemy==2.0.34 psycopg[binary]==3.2.1 pydantic==2.9.2 +pytest==8.3.3 +pytest-cov==5.0.0 +httpx==0.27.2 diff --git a/lecture4/tests/conftest.py b/lecture4/tests/conftest.py new file mode 100644 index 00000000..45527c07 --- /dev/null +++ b/lecture4/tests/conftest.py @@ -0,0 +1,53 @@ +import os +import pathlib +import psycopg +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, text +from sqlalchemy.orm import sessionmaker + +from main import app +import database as db_mod # your get_db uses SessionLocal bound to DATABASE_URL + +# Тестовая БД и инициализация схемы +TEST_DSN_SQLA = os.environ.get("DATABASE_URL", "postgresql+psycopg://shop:shop@localhost:5432/shop_test") +TEST_DSN_PSQL = TEST_DSN_SQLA.replace("+psycopg", "") # для psql команд + +INIT_SQL = (pathlib.Path(__file__).resolve().parents[1] / "init.sql").read_text() + +engine = create_engine(TEST_DSN_SQLA, pool_pre_ping=True, future=True) +SessionTesting = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True) + +def reset_schema(): + with psycopg.connect(TEST_DSN_PSQL, autocommit=True) as c, c.cursor() as cur: + cur.execute("TRUNCATE cart_items, carts, items RESTART IDENTITY") + # начальные данные и индекс + cur.execute(""" + INSERT INTO items(name,price,deleted) VALUES ('A',100,false),('B',200,false); + """) + +@pytest.fixture(scope="session", autouse=True) +def _ensure_schema(): + # применяем init.sql один раз на тестовую БД + with psycopg.connect(TEST_DSN_PSQL, autocommit=True) as c, c.cursor() as cur: + cur.execute(INIT_SQL) + +@pytest.fixture(autouse=True) +def _db_clean(): + reset_schema() + yield + reset_schema() + +@pytest.fixture +def client(monkeypatch): + # Подменяем зависимость get_db на тестовую сессию + def _get_db_override(): + db = SessionTesting() + try: + yield db + finally: + db.close() + app.dependency_overrides[db_mod.get_db] = _get_db_override + with TestClient(app) as c: + yield c + app.dependency_overrides.clear() diff --git a/lecture4/tests/test_carts.py b/lecture4/tests/test_carts.py new file mode 100644 index 00000000..b20a613f --- /dev/null +++ b/lecture4/tests/test_carts.py @@ -0,0 +1,33 @@ +def test_cart_create_get_add_and_price(client): + # есть базовые items A=100, B=200 + r = client.post("/cart") + assert r.status_code == 201 + cid = r.json()["id"] + + r = client.get(f"/cart/{cid}"); assert r.status_code == 200 + assert r.json()["price"] == 0.0 + + client.post(f"/cart/{cid}/add/1") # A + client.post(f"/cart/{cid}/add/2") # B + client.post(f"/cart/{cid}/add/1") # A again + + r = client.get(f"/cart/{cid}") + assert r.status_code == 200 + assert r.json()["price"] == 100.0*2 + 200.0*1 + items = {i["id"]: i["quantity"] for i in r.json()["items"]} + assert items[1] == 2 and items[2] == 1 + +def test_cart_list_filters_and_quantity_budget(client): + c1 = client.post("/cart").json()["id"] + c2 = client.post("/cart").json()["id"] + client.post(f"/cart/{c1}/add/1"); client.post(f"/cart/{c1}/add/1") + client.post(f"/cart/{c2}/add/2") + + # price filter + r = client.get("/cart", params={"min_price": 150}) + assert all(x["price"] >= 150 for x in r.json()) + + # quantity budget (min_quantity/max_quantity semantics из ДЗ) + r = client.get("/cart", params={"max_quantity": 2}) + total_qty = sum(sum(i["quantity"] for i in x["items"]) for x in r.json()) + assert total_qty <= 2 diff --git a/lecture4/tests/test_items.py b/lecture4/tests/test_items.py new file mode 100644 index 00000000..a144a0e2 --- /dev/null +++ b/lecture4/tests/test_items.py @@ -0,0 +1,49 @@ +from http import HTTPStatus + +def test_create_and_get_item(client): + r = client.post("/item", json={"name": "X", "price": 9.99}) + assert r.status_code == HTTPStatus.CREATED + iid = r.json()["id"] + + r = client.get(f"/item/{iid}") + assert r.status_code == 200 + assert r.json()["name"] == "X" + assert r.json()["price"] == 9.99 + +def test_list_filters_pagination(client): + client.post("/item", json={"name": "C", "price": 300}) + r = client.get("/item", params={"min_price": 150, "offset": 0, "limit": 2}) + assert r.status_code == 200 + assert all(i["price"] >= 150 for i in r.json()) + +def test_put_patch_delete_and_not_modified(client): + r = client.post("/item", json={"name": "Y", "price": 10}) + iid = r.json()["id"] + + r = client.put(f"/item/{iid}", json={"name": "Y2", "price": 20}) + assert r.status_code == 200 + assert r.json()["name"] == "Y2" + + r = client.patch(f"/item/{iid}", json={"price": 25}) + assert r.status_code == 200 + assert r.json()["price"] == 25.0 + + r = client.delete(f"/item/{iid}") + assert r.status_code == 200 or r.status_code == 204 or r.text == "" + + # повторное изменение удалённого — 304 NOT_MODIFIED согласно ТЗ + r = client.put(f"/item/{iid}", json={"name": "Z", "price": 5}) + assert r.status_code == HTTPStatus.NOT_MODIFIED + + # get удалённого — 404 + r = client.get(f"/item/{iid}") + assert r.status_code == HTTPStatus.NOT_FOUND + +def test_list_show_deleted_flag(client): + r = client.post("/item", json={"name": "D", "price": 50}); iid = r.json()["id"] + client.delete(f"/item/{iid}") + r = client.get("/item") + assert all(not it["deleted"] for it in r.json()) + r = client.get("/item", params={"show_deleted": True}) + ids = [it["id"] for it in r.json()] + assert iid in ids From 6a5169df4729fe0a46aa14510ed8c14d3727c36c Mon Sep 17 00:00:00 2001 From: safroalex Date: Tue, 21 Oct 2025 16:48:13 +0300 Subject: [PATCH 5/5] Update README for hw4 and add hw5 instructions --- lecture4/README.md | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/lecture4/README.md b/lecture4/README.md index 65e8e90a..71cfcc5f 100644 --- a/lecture4/README.md +++ b/lecture4/README.md @@ -1,4 +1,4 @@ -## ДЗ +## Задание hw4 За каждый пункт - 1 балл @@ -14,7 +14,7 @@ показать что нет phantom reads при serializable *Тут зависит от того какую БД вы выбрали, разные БД могут поддерживать разные уровни изоляции -## Итог +## Итог hw 4 БД в docker-compose.yml — добавлен сервис db: postgres:16, volume, healthcheck. API переведён на БД — FastAPI + SQLAlchemy 2.x, DSN postgresql+psycopg://shop:shop@db:5432/shop, таблицы items, carts, cart_items. @@ -84,4 +84,36 @@ T2 SER iso: serializable T2 SER committed T1 SER count2 (same): 1 T1 SER committed -``` \ No newline at end of file +``` + + +## hw5 + +### Задание +1) Добиться 95% покрытия тестами вашей второй домашки - 1 балл + +2) Настроить автозапуск этих тестов в CI, если вы подключали сторонюю БД, то можно посмотреть вот [сюда](https://dev.to/kashifsoofi/integration-test-postgres-using-github-actions-3lln), чтобы поддержать тесты с ней в CI. По итогу у вас должен получится зеленый пайплайн - оценивается в еще 2 балла. + + +### Итог hw5 + +--- + +## Локальный прогон (через Docker) + +```bash +# 1) поднять Postgres и API-контейнер +docker compose up -d + +# 2) создать тестовую БД и накатить схему +docker compose exec db psql -U shop -d postgres -c "CREATE DATABASE shop_test" || true +docker compose exec db psql -U shop -d shop_test -f /docker-entrypoint-initdb.d/00_init.sql + +# 3) запустить тесты внутри контейнера api +docker compose exec \ + -e DATABASE_URL="postgresql+psycopg://shop:shop@db:5432/shop_test" \ + api pytest +``` + +## Пример зеленого ci +https://github.com/safroalex/python-backend-hw/actions/runs/18685722083/job/53277677344 \ No newline at end of file