diff --git a/.github/workflows/hw2-tests-custom.yml b/.github/workflows/hw2-tests-custom.yml new file mode 100644 index 00000000..2cee0559 --- /dev/null +++ b/.github/workflows/hw2-tests-custom.yml @@ -0,0 +1,69 @@ +name: "HW2 Tests Custom (with Postgres)" + +# Запускаем тесты при изменении файлов в hw2/hw/ +on: + pull_request: + branches: [ main ] + paths: [ 'hw2/hw/**' ] + push: + branches: [ main ] + paths: [ 'hw2/hw/**' ] + +jobs: + test-hw2-custom: + runs-on: ubuntu-latest + defaults: + run: + working-directory: hw2/hw + + strategy: + matrix: + python-version: ["3.12", "3.13"] + + services: + postgres: + image: postgres:latest + env: + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: shop_api + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run tests with in-memory database + env: + PYTHONPATH: ${{ github.workspace }}/hw2/hw + SHOP_API_DB_TYPE: in-memory + run: | + pytest test_homework2.py -v + + - name: Run tests with Postgres database + env: + PYTHONPATH: ${{ github.workspace }}/hw2/hw + SHOP_API_DB_TYPE: postgres + POSTGRES_ADDRESS: localhost + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: shop_api + POSTGRES_PORT: 5432 + run: | + pytest test_homework2.py -v diff --git a/hw1/app.py b/hw1/app.py index 6107b870..aae039dc 100644 --- a/hw1/app.py +++ b/hw1/app.py @@ -1,4 +1,159 @@ from typing import Any, Awaitable, Callable +from urllib.parse import parse_qs +import json +import math +import re + + +HTTP_OK = 200 +HTTP_BAD_REQUEST = 400 +HTTP_NOT_FOUND = 404 +HTTP_UNPROCESSABLE_ENTITY = 422 + + +def fibonacci(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 + + +def factorial(n: int) -> int: + return math.factorial(n) + + +def mean(numbers: list[int | float]) -> float: + if not numbers: + raise ValueError('Empty list') + return sum(numbers) / len(numbers) + + +async def read_body(receive: Callable[[], Awaitable[dict[str, Any]]]) -> bytes: + body_parts = [] + while True: + message = await receive() + if message['type'] != 'http.request': + continue + + body_parts.append(message.get('body', b'')) + if not message.get('more_body', False): + break + + return b''.join(body_parts) + + +async def send_response( + send: Callable[[dict[str, Any]], Awaitable[None]], + status: int, + body: dict[str, Any] | None = None, +): + await send({ + 'type': 'http.response.start', + 'status': status, + 'headers': [[b"content-type", b"application/json"]], + }) + + response_body = json.dumps(body if body else {}).encode('utf-8') + await send({ + 'type': 'http.response.body', + 'body': response_body, + }) + + +async def handle_lifespan( + receive: Callable[[], Awaitable[dict[str, Any]]], + send: Callable[[dict[str, Any]], Awaitable[None]], +): + 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 + + +async def handle_fibonacci( + path: str, + send: Callable[[dict[str, Any]], Awaitable[None]], +): + match = re.match(r'^/fibonacci/(.+)$', path) + if not match: + await send_response(send, HTTP_NOT_FOUND) + return + + try: + n = int(match.group(1)) + if n < 0: + await send_response(send, HTTP_BAD_REQUEST) + return + + result = fibonacci(n) + await send_response(send, HTTP_OK, {'result': result}) + except ValueError: + await send_response(send, HTTP_UNPROCESSABLE_ENTITY) + + +async def handle_factorial( + query_string: bytes, + send: Callable[[dict[str, Any]], Awaitable[None]], +): + if not query_string: + await send_response(send, HTTP_UNPROCESSABLE_ENTITY) + return + + params = parse_qs(query_string.decode('utf-8')) + + if 'n' not in params or len(params['n']) != 1: + await send_response(send, HTTP_UNPROCESSABLE_ENTITY) + return + + try: + n = int(params['n'][0]) + if n < 0: + await send_response(send, HTTP_BAD_REQUEST) + return + + result = factorial(n) + await send_response(send, HTTP_OK, {'result': result}) + except ValueError: + await send_response(send, HTTP_UNPROCESSABLE_ENTITY) + + +async def handle_mean( + receive: Callable[[], Awaitable[dict[str, Any]]], + send: Callable[[dict[str, Any]], Awaitable[None]], +): + body_bytes = await read_body(receive) + + if not body_bytes: + await send_response(send, HTTP_UNPROCESSABLE_ENTITY) + return + + try: + data = json.loads(body_bytes.decode('utf-8')) + + if not isinstance(data, list): + await send_response(send, HTTP_UNPROCESSABLE_ENTITY) + return + + if len(data) == 0: + await send_response(send, HTTP_BAD_REQUEST) + return + + if not all(isinstance(x, (int, float)) for x in data): + await send_response(send, HTTP_UNPROCESSABLE_ENTITY) + return + + result = mean(data) + await send_response(send, HTTP_OK, {'result': result}) + except (json.JSONDecodeError, ValueError): + await send_response(send, HTTP_UNPROCESSABLE_ENTITY) async def application( @@ -6,14 +161,37 @@ async def application( receive: Callable[[], Awaitable[dict[str, Any]]], send: Callable[[dict[str, Any]], Awaitable[None]], ): - """ + ''' Args: scope: Словарь с информацией о запросе receive: Корутина для получения сообщений от клиента send: Корутина для отправки сообщений клиенту - """ - # TODO: Ваша реализация здесь + ''' + if scope['type'] == 'lifespan': + await handle_lifespan(receive, send) + return + + if scope['type'] != 'http': + return + + method = scope['method'] + path = scope['path'] + + if method != 'GET': + await send_response(send, HTTP_NOT_FOUND) + return + + if path.startswith('/fibonacci/'): + await handle_fibonacci(path, send) + elif path == '/factorial': + query_string = scope.get('query_string', b'') + await handle_factorial(query_string, send) + elif path == '/mean': + await handle_mean(receive, send) + else: + await send_response(send, HTTP_NOT_FOUND) + -if __name__ == "__main__": +if __name__ == '__main__': import uvicorn - uvicorn.run("app:application", host="0.0.0.0", port=8000, reload=True) + uvicorn.run('app:application', host='0.0.0.0', port=8000, reload=True) diff --git a/hw2/hw/Dockerfile b/hw2/hw/Dockerfile new file mode 100644 index 00000000..27b0b636 --- /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_ROOT/src +COPY . ./ + +ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ + PATH=$APP_ROOT/src/.venv/bin:$PATH + +RUN pip install -r requirements.txt + +FROM base as local + +CMD ["uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"] diff --git a/hw2/hw/DockerfilePytest b/hw2/hw/DockerfilePytest new file mode 100644 index 00000000..f2416f90 --- /dev/null +++ b/hw2/hw/DockerfilePytest @@ -0,0 +1,25 @@ +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_ROOT/src +COPY . ./ + +ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ + PATH=$APP_ROOT/src/.venv/bin:$PATH + +RUN pip install -r requirements.txt + +FROM base as local + +CMD ["bash", "-c", "\ + SHOP_API_DB_TYPE=in-memory pytest -vv --cov=. --cov-report= --showlocals --strict ./test_homework2.py && \ + SHOP_API_DB_TYPE=postgres pytest -vv --cov=. --cov-append --cov-report=term-missing --showlocals --strict ./test_homework2.py"] diff --git a/hw2/hw/dash_example.png b/hw2/hw/dash_example.png new file mode 100644 index 00000000..1c65a4f2 Binary files /dev/null and b/hw2/hw/dash_example.png differ diff --git a/hw2/hw/docker-compose-pytest.yml b/hw2/hw/docker-compose-pytest.yml new file mode 100644 index 00000000..c9fc61b5 --- /dev/null +++ b/hw2/hw/docker-compose-pytest.yml @@ -0,0 +1,60 @@ +version: "3" + +services: + + local: + build: + context: . + dockerfile: ./DockerfilePytest + target: local + restart: no + ports: + - 8080:8080 + environment: + PYTHONPATH: . + POSTGRES_ADDRESS: postgres + POSTGRES_PORT: ${POSTGRES_PORT} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + depends_on: + postgres: + condition: service_healthy + + postgres: + image: postgres:latest + ports: + - "${POSTGRES_PORT}:5432" + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + healthcheck: + test: [ "CMD-SHELL", "pg_isready" ] + interval: 1s + timeout: 5s + retries: 10 + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + volumes: + - grafana-storage:/var/lib/grafana + + 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 + +volumes: + grafana-storage: {} diff --git a/hw2/hw/docker-compose.yml b/hw2/hw/docker-compose.yml new file mode 100644 index 00000000..b033addb --- /dev/null +++ b/hw2/hw/docker-compose.yml @@ -0,0 +1,64 @@ +version: "3" + +services: + + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: always + ports: + - 8080:8080 + environment: + PYTHONPATH: . + SHOP_API_DB_TYPE: postgres + POSTGRES_ADDRESS: postgres + POSTGRES_PORT: ${POSTGRES_PORT} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + depends_on: + postgres: + condition: service_healthy + + postgres: + image: postgres:latest + ports: + - "${POSTGRES_PORT}:5432" + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + healthcheck: + test: [ "CMD-SHELL", "pg_isready" ] + interval: 1s + timeout: 5s + retries: 10 + volumes: + - pgdata:/var/lib/postgresql + + grafana: + image: grafana/grafana:latest + ports: + - 3000:3000 + restart: always + volumes: + - grafana-storage:/var/lib/grafana + + 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 + +volumes: + grafana-storage: {} + pgdata: {} diff --git a/hw2/hw/requirements.txt b/hw2/hw/requirements.txt index 207dcf5c..50f4de57 100644 --- a/hw2/hw/requirements.txt +++ b/hw2/hw/requirements.txt @@ -1,9 +1,14 @@ # Основные зависимости для ASGI приложения fastapi>=0.117.1 uvicorn>=0.24.0 +prometheus-client +prometheus-fastapi-instrumentator +SQLAlchemy +psycopg2 # Зависимости для тестирования pytest>=7.4.0 pytest-asyncio>=0.21.0 +pytest-cov httpx>=0.27.2 Faker>=37.8.0 diff --git a/hw2/hw/settings/prometheus/prometheus.yml b/hw2/hw/settings/prometheus/prometheus.yml new file mode 100644 index 00000000..7fa1951b --- /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 diff --git a/hw2/hw/shop_api/in_memory_repository.py b/hw2/hw/shop_api/in_memory_repository.py new file mode 100644 index 00000000..4bf694fd --- /dev/null +++ b/hw2/hw/shop_api/in_memory_repository.py @@ -0,0 +1,86 @@ +from typing import List, Optional + +from shop_api.models import CartItem, Cart, Item +from shop_api.models import CartNotFoundException, Item, ItemNotFoundException + + +class Repository: + carts: List[Cart] = [] + items: List[Item] = [] + + def create_cart(self) -> Cart: + cart_id = len(self.carts) + cart = Cart(id=cart_id, items=[], price=0.0) + self.carts.append(cart) + return cart.model_copy(deep=True) + + def _get_cart(self, cart_id: int) -> Cart: + if cart_id >= len(self.carts): + raise CartNotFoundException() + return self.carts[cart_id] + + def get_cart(self, cart_id: int) -> Cart: + return self._get_cart(cart_id).model_copy(deep=True) + + def get_carts(self, offset: int, limit: int) -> List[Cart]: + return [cart.model_copy(deep=True) for cart in self.carts[offset:offset+limit]] + + def add_item_to_cart(self, cart_id: int, item_id: int): + cart: Cart = self._get_cart(cart_id) + item: Item = self.get_item(item_id) + + for cart_item in cart.items: + if cart_item.id == item.id: + cart_item.quantity += 1 + break + else: + cart.items.append(CartItem(id=item.id, name=item.name, quantity=1, + available=not item.deleted)) + + cart.price += item.price + + def create_item(self, name: str, price: float) -> Item: + item_id = len(self.items) + item = Item(id=item_id, name=name, price=price, deleted=False) + self.items.append(item) + return item.model_copy(deep=True) + + def _get_item(self, item_id: int) -> Item: + if item_id >= len(self.items): + raise ItemNotFoundException() + return self.items[item_id] + + def get_item(self, item_id: int) -> Item: + item: Item = self._get_item(item_id) + if item.deleted: + raise ItemNotFoundException() + + return item.model_copy(deep=True) + + def get_items(self, offset: int, limit: int) -> List[Item]: + return [item.model_copy(deep=True) for item in self.items[offset:offset+limit]] + + def replace_item(self, item_id: int, name: str, price: float) -> Item: + if item_id >= len(self.items): + raise ItemNotFoundException() + + item = Item(id=item_id, name=name, price=price, deleted=False) + self.items[item_id] = item + return item.model_copy(deep=True) + + def update_item(self, item_id: int, name: Optional[str], price: Optional[float]) -> Optional[Item]: + item: Item = self._get_item(item_id) + + if item.deleted: + return None + + if name is not None: + item.name = name + if price is not None: + item.price = price + + return item.model_copy(deep=True) + + def delete_item(self, item_id: int): + item: Item = self._get_item(item_id) + item.deleted = True diff --git a/hw2/hw/shop_api/main.py b/hw2/hw/shop_api/main.py index f60a8c60..9766ec2f 100644 --- a/hw2/hw/shop_api/main.py +++ b/hw2/hw/shop_api/main.py @@ -1,3 +1,186 @@ -from fastapi import FastAPI +import os +from typing import List, Optional -app = FastAPI(title="Shop API") +from fastapi import FastAPI, HTTPException, status +from fastapi.encoders import jsonable_encoder +from fastapi.responses import Response, JSONResponse +from prometheus_client import Counter +from prometheus_fastapi_instrumentator import Instrumentator +from pydantic import NonNegativeInt, PositiveInt, NonNegativeFloat, BaseModel + +from shop_api.models import Cart, Item +from shop_api.models import CartNotFoundException, ItemNotFoundException +from shop_api.in_memory_repository import Repository as InMemoryRepository +from shop_api.postgres_repository import Repository as PostgresRepository + + +def get_repository(base: str): + if base == 'in-memory': + return InMemoryRepository() + elif base == 'postgres': + return PostgresRepository() + raise Exception('Unknown base') + + +repository = get_repository(os.environ.get('SHOP_API_DB_TYPE', 'in-memory')) + +app = FastAPI(title='Shop API') +instrumentator = Instrumentator().instrument(app).expose(app) + + +@app.post('/cart') +async def create_cart(): + new_cart: Cart = repository.create_cart() + return JSONResponse( + content={'id': new_cart.id}, + headers={'location': f'/cart/{new_cart.id}'}, + status_code=status.HTTP_201_CREATED, + ) + + +test_get_cart_counter = Counter('get_cart_counter', 'Test custum counter') + + +@app.get('/cart/{id}') +async def get_cart(id: NonNegativeInt): + test_get_cart_counter.inc() + try: + cart = repository.get_cart(id) + except CartNotFoundException: + raise HTTPException(status_code=404, detail='Cart not found') + except Exception as e: + print("Unexpected internal error: ", e) + raise HTTPException(status_code=500, detail='Internal server error') + return cart + + +@app.get('/cart') +async def get_carts(offset: NonNegativeInt = 0, + limit: PositiveInt = 10, + min_price: Optional[NonNegativeFloat] = None, + max_price: Optional[NonNegativeFloat] = None, + min_quantity: Optional[NonNegativeInt] = None, + max_quantity: Optional[NonNegativeInt] = None): + carts: List[Cart] = repository.get_carts(offset, limit) + result = [] + for cart in carts: + if min_price is not None and cart.price < min_price: + continue + if max_price is not None and cart.price > max_price: + continue + + cart_quantity = sum([item.quantity for item in cart.items]) + if min_quantity is not None and cart_quantity < min_quantity: + continue + if max_quantity is not None and cart_quantity > max_quantity: + continue + + result.append(cart) + + return result + + +@app.post('/cart/{cart_id}/add/{item_id}') +async def add_item_to_cart(cart_id: NonNegativeInt, item_id: NonNegativeInt): + try: + cart: Cart = repository.add_item_to_cart(cart_id, item_id) + except CartNotFoundException: + raise HTTPException(status_code=404, detail='Cart not found') + except ItemNotFoundException: + raise HTTPException(status_code=404, detail='Item not found') + except Exception as e: + print("Unexpected internal error: ", e) + raise HTTPException(status_code=500, detail='Internal server errorss') + + +class CreateItemRequestBody(BaseModel): + name: str + price: float + + +@app.post('/item', status_code=201) +async def create_item(body: CreateItemRequestBody): + new_item: Item = repository.create_item(name=body.name, price=body.price) + return JSONResponse( + content=jsonable_encoder(new_item), + headers={'location': f'/item/{new_item.id}'}, + status_code=status.HTTP_201_CREATED, + ) + + +@app.get('/item/{id}') +async def get_item(id: NonNegativeInt): + try: + item: Item = repository.get_item(id) + except ItemNotFoundException: + raise HTTPException(status_code=404, detail='Item not found') + except Exception as e: + print("Unexpected internal error: ", e) + raise HTTPException(status_code=500, detail='Internal server error') + return item + + +@app.get('/item') +async def get_items(offset: NonNegativeInt = 0, + limit: PositiveInt = 10, + min_price: Optional[NonNegativeFloat] = None, + max_price: Optional[NonNegativeFloat] = None, + show_deleted: bool = False): + items: List[Item] = repository.get_items(offset, limit) + result = [] + for item in items: + if min_price is not None and item.price < min_price: + continue + if max_price is not None and item.price > max_price: + continue + if not show_deleted and item.deleted: + continue + + result.append(item) + + return result + + +class ReplaceItemRequestBody(BaseModel): + name: str + price: float + + +@app.put('/item/{id}') +async def replace_item(id: NonNegativeInt, body: ReplaceItemRequestBody): + try: + item: Item = repository.replace_item(item_id=id, name=body.name, price=body.price) + except ItemNotFoundException: + raise HTTPException(status_code=404, detail='Item not found') + except Exception as e: + print("Unexpected internal error: ", e) + raise HTTPException(status_code=500, detail='Internal server error') + return item + + +class UpdateItemRequestBody(BaseModel): + model_config = {'extra': 'forbid'} + + name: Optional[str] = None + price: Optional[float] = None + + +@app.patch('/item/{id}') +async def update_item(id: NonNegativeInt, body: UpdateItemRequestBody): + try: + updated_item: Optional[Item] = repository.update_item( + item_id=id, name=body.name, price=body.price) + except ItemNotFoundException: + raise HTTPException(status_code=404, detail='Item not found') + except Exception as e: + print("Unexpected internal error: ", e) + raise HTTPException(status_code=500, detail='Internal server error') + + if not updated_item: + return Response(status_code=status.HTTP_304_NOT_MODIFIED) + return updated_item + + +@app.delete('/item/{id}') +async def delete_item(id: NonNegativeInt): + repository.delete_item(item_id=id) diff --git a/hw2/hw/shop_api/models.py b/hw2/hw/shop_api/models.py new file mode 100644 index 00000000..eef0ecc4 --- /dev/null +++ b/hw2/hw/shop_api/models.py @@ -0,0 +1,27 @@ +from typing import List + +from pydantic import BaseModel + + +class CartItem(BaseModel): + id: int + name: str + quantity: int + available: bool + +class Cart(BaseModel): + id: int + items: List[CartItem] + price: float + +class Item(BaseModel): + id: int + name: str + price: float + deleted: bool + +class CartNotFoundException(Exception): + pass + +class ItemNotFoundException(Exception): + pass diff --git a/hw2/hw/shop_api/postgres_repository.py b/hw2/hw/shop_api/postgres_repository.py new file mode 100644 index 00000000..dc7da7d4 --- /dev/null +++ b/hw2/hw/shop_api/postgres_repository.py @@ -0,0 +1,200 @@ +import os +from typing import List, Optional + +from sqlalchemy import create_engine, Engine, ForeignKey, select, String +from sqlalchemy.orm import DeclarativeBase, joinedload, Mapped, mapped_column, Session, relationship + +from shop_api import models + +class Base(DeclarativeBase): + pass + + +class Item(Base): + __tablename__ = "items" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(50)) + price: Mapped[float] = mapped_column(default=0) + deleted: Mapped[bool] = mapped_column(default=False) + + def __repr__(self) -> str: + return f"Item(id={self.id!r}, name={self.name!r}, price={self.price!r}, deleted={self.deleted!r})" + + +class CartItem(Base): + __tablename__ = "cart_items" + + id: Mapped[int] = mapped_column(primary_key=True) + item_id: Mapped[int] = mapped_column(ForeignKey("items.id")) + item: Mapped["Item"] = relationship() + quantity: Mapped[int] + cart_id: Mapped[int] = mapped_column(ForeignKey("carts.id")) + cart: Mapped["Cart"] = relationship(back_populates="items") + + def __repr__(self) -> str: + return f"CartItem(id={self.id!r}, item_id={self.item_id!r}, quantity={self.quantity!r})" + + +class Cart(Base): + __tablename__ = "carts" + + id: Mapped[int] = mapped_column(primary_key=True) + items: Mapped[List["CartItem"]] = relationship(back_populates="cart") + price: Mapped[float] + + def __repr__(self) -> str: + return f"Cart(id={self.id!r}, items=[{', '.join([f'{item.id!r}' for item in self.items])}], quanpricetity={self.price!r})" + + +def ItemToModel(item: Item) -> models.Item: + return models.Item( + id=item.id, + name=item.name, + price=item.price, + deleted=item.deleted + ) + + +def CartItemToModel(cart_item: CartItem) -> models.CartItem: + return models.CartItem( + id=cart_item.item.id, + name=cart_item.item.name, + quantity=cart_item.quantity, + available=not cart_item.item.deleted + ) + + +def CartToModel(cart: Cart) -> models.Cart: + return models.Cart( + id=cart.id, + items=[CartItemToModel(cart_item) for cart_item in cart.items], + price=cart.price + ) + + +class Repository: + engine: Engine + + def __init__(self): + postgres_user = os.environ['POSTGRES_USER'] + postgres_password = os.environ['POSTGRES_PASSWORD'] + postgres_address = os.environ['POSTGRES_ADDRESS'] + postgres_port = os.environ['POSTGRES_PORT'] + self.engine = create_engine(f'postgresql+psycopg2://{postgres_user}:{postgres_password}@{postgres_address}:{postgres_port}/shop_api') + Base.metadata.create_all(self.engine) + + def create_cart(self) -> models.Cart: + with Session(self.engine) as session: + cart = Cart(items=[], price=0.0) + session.add(cart) + session.commit() + + return CartToModel(cart) + + def get_cart(self, cart_id: int) -> models.Cart: + with Session(self.engine) as session: + stmt = select(Cart).options(joinedload(Cart.items)).where(Cart.id == cart_id) + cart = session.scalar(stmt) + + if cart is None: + raise models.CartNotFoundException() + return CartToModel(cart) + + def get_carts(self, offset: int, limit: int) -> List[models.Cart]: + with Session(self.engine) as session: + stmt = select(Cart).options(joinedload(Cart.items)).where(Cart.id > offset).limit(limit) + carts = session.scalars(stmt).unique() + + return [CartToModel(cart) for cart in carts] + + def add_item_to_cart(self, cart_id: int, item_id: int): + with Session(self.engine) as session: + stmt = ( + select(CartItem).options(joinedload(CartItem.cart), joinedload(CartItem.item)) + .where(CartItem.cart_id == cart_id) + .where(CartItem.item_id == item_id) + ) + cart_item = session.scalar(stmt) + if cart_item is not None: + cart_item.quantity += 1 + cart_item.cart.price += cart_item.item.price + session.commit() + return + + stmt = select(Cart).where(Cart.id == cart_id) + cart = session.scalar(stmt) + if cart is None: + raise models.CartNotFoundException() + + stmt = select(Item).where(Item.id == item_id) + item = session.scalar(stmt) + if item is None: + raise models.ItemNotFoundException() + + cart.items.append(CartItem(item_id=item_id, quantity=1, cart_id=cart_id)) + cart.price += item.price + session.commit() + + def create_item(self, name: str, price: float) -> models.Item: + with Session(self.engine) as session: + item = Item(name=name, price=price) + session.add(item) + session.commit() + + return ItemToModel(item) + + def _get_item(self, item_id: int, session: Session) -> Item: + stmt = select(Item).where(Item.id == item_id) + item = session.scalar(stmt) + + if item is None: + raise models.ItemNotFoundException() + return item + + def get_item(self, item_id: int) -> models.Item: + with Session(self.engine) as session: + item: Item = self._get_item(item_id, session) + if item.deleted: + raise models.ItemNotFoundException() + return ItemToModel(item) + + def get_items(self, offset: int, limit: int) -> List[models.Item]: + with Session(self.engine) as session: + stmt = select(Item).where(Item.id > offset).where(not Item.deleted).limit(limit) + items = session.scalars(stmt) + + return [ItemToModel(item) for item in items] + + def replace_item(self, item_id: int, name: str, price: float) -> models.Item: + with Session(self.engine) as session: + item: Item = self._get_item(item_id, session) + + item.name = name + item.price = price + item.deleted = False + + session.commit() + return ItemToModel(item) + + + def update_item(self, item_id: int, name: Optional[str], price: Optional[float]) -> Optional[Item]: + with Session(self.engine) as session: + item: Item = self._get_item(item_id, session) + if item.deleted: + return + + if name is not None: + item.name = name + if price is not None: + item.price = price + + session.commit() + return ItemToModel(item) + + def delete_item(self, item_id: int): + with Session(self.engine) as session: + item: Item = self._get_item(item_id, session) + if not item.deleted: + item.deleted = True + session.commit() diff --git a/hw2/hw/test_homework2.py b/hw2/hw/test_homework2.py index 60a1f36a..52dd7fb0 100644 --- a/hw2/hw/test_homework2.py +++ b/hw2/hw/test_homework2.py @@ -37,7 +37,8 @@ def existing_not_empty_carts(existing_items: list[int]) -> list[int]: for i in range(20): cart_id: int = client.post("/cart").json()["id"] for item_id in faker.random_elements(existing_items, unique=False, length=i): - client.post(f"/cart/{cart_id}/add/{item_id}") + response = client.post(f"/cart/{cart_id}/add/{item_id}") + assert response.status_code == HTTPStatus.OK carts.append(cart_id) @@ -75,6 +76,11 @@ def deleted_item(existing_item: dict[str, Any]) -> dict[str, Any]: return existing_item +@pytest.fixture() +def non_existing_item() -> dict[str, Any]: + return {"id": 100500, "name": "non existing", "price": 0, "deleted": False} + + def test_post_cart() -> None: response = client.post("/cart") @@ -106,13 +112,20 @@ def test_get_cart(request, cart: int, not_empty: bool) -> None: for item in response_json["items"]: item_id = item["id"] - price += client.get(f"/item/{item_id}").json()["price"] * item["quantity"] + response = client.get(f"/item/{item_id}") + assert response.status_code == HTTPStatus.OK + price += response.json()["price"] * item["quantity"] assert response_json["price"] == pytest.approx(price, 1e-8) else: assert response_json["price"] == 0.0 +def test_non_existing_get_cart() -> None: + response = client.get(f"/cart/100500") + assert response.status_code == HTTPStatus.NOT_FOUND + + @pytest.mark.parametrize( ("query", "status_code"), [ @@ -156,6 +169,28 @@ def test_get_cart_list(query: dict[str, Any], status_code: int): assert quantity <= query["max_quantity"] +@pytest.mark.parametrize( + ("use_existing_cart", "use_existing_item", "status_code"), + [ + (True, True, HTTPStatus.OK), + (False, True, HTTPStatus.NOT_FOUND), + (True, False, HTTPStatus.NOT_FOUND), + ], +) +def test_add_item_to_cart( + existing_empty_cart_id: int, + existing_item: dict[str, Any], + use_existing_cart: bool, + use_existing_item: bool, + status_code: int +): + cart_id = existing_empty_cart_id if use_existing_cart else 100500 + item_id = existing_item["id"] if use_existing_item else 100500 + response = client.post(f"/cart/{cart_id}/add/{item_id}") + + assert response.status_code == status_code + + def test_post_item() -> None: item = {"name": "test item", "price": 9.99} response = client.post("/item", json=item) @@ -211,19 +246,22 @@ def test_get_item_list(query: dict[str, Any], status_code: int) -> None: @pytest.mark.parametrize( - ("body", "status_code"), + ("item", "body", "status_code"), [ - ({}, HTTPStatus.UNPROCESSABLE_ENTITY), - ({"price": 9.99}, HTTPStatus.UNPROCESSABLE_ENTITY), - ({"name": "new name", "price": 9.99}, HTTPStatus.OK), + ("existing_item", {}, HTTPStatus.UNPROCESSABLE_ENTITY), + ("existing_item", {"price": 9.99}, HTTPStatus.UNPROCESSABLE_ENTITY), + ("existing_item", {"name": "new name", "price": 9.99}, HTTPStatus.OK), + ("non_existing_item", {"name": "new name", "price": 9.99}, HTTPStatus.NOT_FOUND), ], ) def test_put_item( - existing_item: dict[str, Any], + request, + existing_item, + item, body: dict[str, Any], status_code: int, ) -> None: - item_id = existing_item["id"] + item_id = request.getfixturevalue(item)["id"] response = client.put(f"/item/{item_id}", json=body) assert response.status_code == status_code @@ -253,6 +291,7 @@ def test_put_item( {"name": "new name", "price": 9.99, "deleted": True}, HTTPStatus.UNPROCESSABLE_ENTITY, ), + ("non_existing_item", {"name": "new name", "price": 9.99}, HTTPStatus.NOT_FOUND), ], ) def test_patch_item(request, item: str, body: dict[str, Any], status_code: int) -> None: diff --git a/lecture4/hw/Dockerfile b/lecture4/hw/Dockerfile new file mode 100644 index 00000000..7c8ff183 --- /dev/null +++ b/lecture4/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_ROOT/src +COPY . ./ + +ENV VIRTUAL_ENV=$APP_ROOT/src/.venv \ + PATH=$APP_ROOT/src/.venv/bin:$PATH + +RUN pip install -r requirements.txt + +FROM base as local + +CMD ["python", "demo.py"] diff --git a/lecture4/hw/demo.py b/lecture4/hw/demo.py new file mode 100644 index 00000000..8a63fd17 --- /dev/null +++ b/lecture4/hw/demo.py @@ -0,0 +1,264 @@ +import os +import time +import threading +from sqlalchemy import create_engine, select, String, text +from sqlalchemy.orm import Mapped, mapped_column, Session, DeclarativeBase +from sqlalchemy.pool import NullPool + +POSTGRES_USER = os.getenv("POSTGRES_USER", "user") +POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD", "password") +POSTGRES_DB = os.getenv("POSTGRES_DB", "shop_api") +POSTGRES_ADDRESS = os.getenv("POSTGRES_ADDRESS", "localhost") +POSTGRES_PORT = os.getenv("POSTGRES_PORT", "5432") + +DATABASE_URL = ( + f"postgresql+psycopg2://{POSTGRES_USER}:{POSTGRES_PASSWORD}" + f"@{POSTGRES_ADDRESS}:{POSTGRES_PORT}/{POSTGRES_DB}" +) + +class Base(DeclarativeBase): + pass + + +class Item(Base): + __tablename__ = "items" + + id: Mapped[int] = mapped_column(primary_key=True) + name: Mapped[str] = mapped_column(String(50)) + price: Mapped[float] = mapped_column(default=0) + deleted: Mapped[bool] = mapped_column(default=False) + + def __repr__(self) -> str: + return f"Item(id={self.id!r}, name={self.name!r}, price={self.price!r})" + + +def create_engine_with_isolation(isolation_level: str): + return create_engine( + DATABASE_URL, + isolation_level=isolation_level, + ) + + +def init_database(): + engine = create_engine(DATABASE_URL) + Base.metadata.drop_all(engine) + Base.metadata.create_all(engine) + + with Session(engine) as session: + session.add(Item(id=1, name="Item1", price=1000)) + session.add(Item(id=2, name="Item2", price=2000)) + session.commit() + session.close() + + engine.dispose() + + print("База данных инициализирована\n") + + +def print_section(title: str): + print("=" * 80) + print(f" {title}") + print("=" * 80) + + +def print_subsection(title: str): + print(f"\n--- {title} ---") + + +# =========== +# Dirty Read +# =========== + +def demo_dirty_read(isolation_level): + print_subsection(f"Dirty Read при {isolation_level}") + init_database() + + engine = create_engine_with_isolation(isolation_level) + with Session(engine) as session1: + stmt = select(Item).where(Item.id == 1) + item = session1.scalar(stmt) + price1 = item.price + print(f"[T1] Исходная цена Item1: {item.price}") + + item.price = 5000 + session1.flush() + print(f"[T1] Изменили цену Item1 на {item.price} без коммита") + + session2 = Session(engine) + sesion2_item = session2.scalar(stmt) + print(f"[T2] Прочитали цену Item1: {sesion2_item.price}") + price2 = sesion2_item.price + + session1.rollback() + print("[T1] Откатили изменения") + + session2.expire_all() + sesion2_item = session2.scalar(stmt) + print(f"[T2] Прочитали цену Item1 после отката: {sesion2_item.price}") + + session2.close() + + if price1 != price2: + print(f"Dirty Read. Значение без изменений T1: {price1}, T2 прочитал: {price2}") + else: + print(f"Dirty Read не случился") + if isolation_level == "READ UNCOMMITTED": + print(f"PostgreSQL трактует READ UNCOMMITTED как READ COMMITTED") + + engine.dispose() + +def full_demo_dirty_read(): + print_section(f"Dirty Read") + demo_dirty_read("READ UNCOMMITTED") + demo_dirty_read("READ COMMITTED") + + +# ======================= +# 2. Non-Repeatable Read +# ======================= + +def demo_non_repeatable_read(isolation_level): + print_subsection(f"Non-Repeatable Read при {isolation_level}") + init_database() + + engine = create_engine_with_isolation(isolation_level) + with Session(engine) as session1: + stmt = select(Item).where(Item.id == 1) + item = session1.scalar(stmt) + price1 = item.price + print(f"[T1] Исходная цена Item1: {item.price}") + + with Session(engine) as session2: + sesion2_item = session2.scalar(stmt) + sesion2_item.price = 5000 + print(f"[T2] Изменили цену Item1 на {sesion2_item.price}") + session2.commit() + + session1.expire_all() + + item2 = session1.scalar(stmt) + price2 = item2.price + print(f"[T1] Повторно в той же транзации читаем цену Item1: {item2.price}") + + + if price1 != price2: + print(f"Non-Repeatable Read. Первое чтение T1: {price1}, второе чтение T1: {price2}") + else: + print(f"Non-Repeatable Read не случился") + + engine.dispose() + +def full_demo_non_repeatable_read(): + print_section(f"Non-Repeatable Read") + demo_non_repeatable_read("READ COMMITTED") + demo_non_repeatable_read("REPEATABLE READ") + +# ============= +# Phantom Read +# ============= + +def demo_phantom_reads(isolation_level): + print_subsection(f"Phantom Read при {isolation_level}") + init_database() + + engine = create_engine_with_isolation(isolation_level) + with Session(engine) as session1: + stmt = select(Item).where(Item.price > 1000) + items = session1.scalars(stmt).all() + count1 = len(items) + print(f"[T1] Первое чтение: найдено {count1} товаров") + for item in items: + print(f" {item}") + + with Session(engine) as session2: + new_item = Item(id=3, name="Item3", price=3000) + session2.add(new_item) + session2.commit() + print("[T2] Добавили новый товар Item3 и закоммитили") + + session1.expire_all() + + items = session1.scalars(stmt).all() + count2 = len(items) + print(f"[T1] Второе чтение в той же транзакции: найдено {count2} товаров") + for item in items: + print(f" {item}") + + if count1 != count2: + print(f"Phantom Read. Первое чтение: {count1}, второе: {count2}") + else: + print(f"Phantom Read не случился") + if isolation_level == 'REPEATABLE READ': + print(f"PostgreSQL не допускает PHANTOM READ при REPEATABLE READ") + + engine.dispose() + + +def full_demo_phantom_reads(): + print_section("Phantom Read") + demo_phantom_reads("READ COMMITTED") + demo_phantom_reads("REPEATABLE READ") + demo_phantom_reads("SERIALIZABLE") + +# ============= +# Serialization Anomaly +# ============= + +def demo_serialization_anomaly(isolation_level): + print_subsection(f"Serialization Anomaly при {isolation_level}") + init_database() + + engine = create_engine_with_isolation(isolation_level) + with Session(engine) as session1: + stmt = select(Item).where(Item.price > 1000) + items = session1.scalars(stmt).all() + count1 = len(items) + print(f"[T1] Первое чтение: найдено {count1} товаров") + for item in items: + print(f" {item}") + + new_item = Item(id=3, name="Item3", price=3000) + session1.add(new_item) + print("[T1] Добавили новый товар Item3, но не закоммитили") + + with Session(engine) as session2: + session2_items = session2.scalars(stmt).all() + print(f"[T2] Первое чтение: найдено {len(session2_items)} товаров") + for item in session2_items: + print(f" {item}") + + new_item = Item(id=4, name="Item4", price=4000) + session2.add(new_item) + session2.commit() + print("[T2] Добавили новый товар Item4 и закоммитили") + + try: + print("[T1] Пытаемся закоммитить.") + session1.commit() + print(f"Serialization Anomaly. Получилось закоммитить.") + except Exception as e: + print(f"Serialization Anomaly не случилось, транзакция упала с ошибкой: {e}") + + engine.dispose() + +def full_demo_serialization_anomaly(): + print_section("Serialization Anomaly") + demo_serialization_anomaly("REPEATABLE READ") + demo_serialization_anomaly("SERIALIZABLE") + + +def main(): + try: + full_demo_dirty_read() + full_demo_non_repeatable_read() + full_demo_phantom_reads() + full_demo_serialization_anomaly() + + except Exception as e: + print(f"\nПроизошла ошибка: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + main() diff --git a/lecture4/hw/docker-compose.yml b/lecture4/hw/docker-compose.yml new file mode 100644 index 00000000..29de7cc7 --- /dev/null +++ b/lecture4/hw/docker-compose.yml @@ -0,0 +1,37 @@ +version: "3" + +services: + + local: + build: + context: . + dockerfile: ./Dockerfile + target: local + restart: no + ports: + - 8080:8080 + environment: + PYTHONPATH: . + POSTGRES_ADDRESS: postgres + POSTGRES_PORT: 5432 + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + depends_on: + postgres: + condition: service_healthy + + postgres: + image: postgres:latest + ports: + - "${POSTGRES_PORT}:5432" + restart: always + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + healthcheck: + test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}" ] + interval: 1s + timeout: 5s + retries: 10 diff --git a/lecture4/hw/requirements.txt b/lecture4/hw/requirements.txt new file mode 100644 index 00000000..783fdb00 --- /dev/null +++ b/lecture4/hw/requirements.txt @@ -0,0 +1,2 @@ +SQLAlchemy +psycopg2