Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion .github/workflows/hw2-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@ on:
jobs:
test-hw2:
runs-on: ubuntu-latest

services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_DB: shop_db
POSTGRES_USER: shop_user
POSTGRES_PASSWORD: shop_password
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432

strategy:
matrix:
python-version: ["3.12", "3.13"]
Expand All @@ -34,6 +50,7 @@ jobs:
- name: Run tests
working-directory: hw2/hw
env:
DATABASE_URL: postgresql+psycopg2://shop_user:shop_password@localhost:5432/shop_db
PYTHONPATH: ${{ github.workspace }}/hw2/hw
run: |
pytest test_homework2.py -v
pytest test_homework2.py test_additional_api.py --cov=shop_api --cov-report=term --cov-fail-under=95 -v
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,6 @@ dmypy.json

# macOS
.DS_Store

# data
pgdata/
136 changes: 135 additions & 1 deletion hw1/app.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,60 @@
from typing import Any, Awaitable, Callable
import json
from urllib.parse import parse_qs
import math


def parse_int(value: str) -> int | None:
try:
if value is None or value == "":
return None
return int(value)
except (TypeError, ValueError):
return None

def fibonacci(n: int) -> int:
if n == 0:
return 0
if n == 1:
return 1
a, b = 0, 1
for _ in range(2, n + 1):
a, b = b, a + b
return b

async def send_json(
send: Callable[[dict[str, Any]], Awaitable[None]],
status: int,
payload: dict[str, Any] | list[Any] | None,
) -> None:
body_bytes = json.dumps(payload if payload is not None else {}).encode("utf-8")
await send(
{
"type": "http.response.start",
"status": status,
"headers": [
(b"content-type", b"application/json; charset=utf-8"),
(b"content-length", str(len(body_bytes)).encode("ascii")),
],
}
)
await send({"type": "http.response.body", "body": body_bytes})

async def read_body_bytes(
receive: Callable[[], Awaitable[dict[str, Any]]]
) -> bytes:
chunks: list[bytes] = []
more = True
while more:
message = await receive()
if message.get("type") != "http.request":
break
body = message.get("body", b"") or b""
if body:
chunks.append(body)
more = bool(message.get("more_body"))
return b"".join(chunks)

async def application(
scope: dict[str, Any],
receive: Callable[[], Awaitable[dict[str, Any]]],
Expand All @@ -12,7 +66,87 @@ async def application(
receive: Корутина для получения сообщений от клиента
send: Корутина для отправки сообщений клиенту
"""
# TODO: Ваша реализация здесь
if scope.get("type") != "http":
await send(
{
"type": "http.response.start",
"status": 404,
"headers": [(b"content-type", b"application/json; charset=utf-8")],
}
)
await send({"type": "http.response.body", "body": b"{}"})
return

method: str = scope.get("method", "GET").upper()
raw_path: bytes = scope.get("raw_path") or scope.get("path", "/").encode()
path: str = (raw_path.decode("utf-8") if isinstance(raw_path, (bytes, bytearray)) else str(raw_path))

if method != "GET":
await send_json(send, 404, {})
return

if path == "/factorial":
query_bytes: bytes = scope.get("query_string", b"") or b""
qs = parse_qs(query_bytes.decode("utf-8"), keep_blank_values=True)
n_values = qs.get("n")
if not n_values:
await send_json(send, 422, {})
return
n_raw = n_values[0]
n = parse_int(n_raw)
if n is None:
await send_json(send, 422, {})
return
if n < 0:
await send_json(send, 400, {})
return
await send_json(send, 200, {"result": math.factorial(n)})
return

if path.startswith("/fibonacci"):
parts = path.split("/")
if len(parts) != 3 or parts[2] == "":
await send_json(send, 422, {})
return
n = parse_int(parts[2])
if n is None:
await send_json(send, 422, {})
return
if n < 0:
await send_json(send, 400, {})
return
await send_json(send, 200, {"result": fibonacci(n)})
return

if path == "/mean":
body = await read_body_bytes(receive)
if not body:
await send_json(send, 422, {})
return
try:
data = json.loads(body.decode("utf-8"))
except Exception:
await send_json(send, 422, {})
return
if not isinstance(data, list):
await send_json(send, 422, {})
return
if len(data) == 0:
await send_json(send, 400, {})
return
total = 0.0
count = 0
for item in data:
if not isinstance(item, (int, float)):
await send_json(send, 422, {})
return
total += float(item)
count += 1
result = total / count if count > 0 else 0.0
await send_json(send, 200, {"result": result})
return

await send_json(send, 404, {})

if __name__ == "__main__":
import uvicorn
Expand Down
24 changes: 24 additions & 0 deletions hw2/hw/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
FROM python:3.12 AS base

ARG PYTHONFAULTHANDLER=1 \
PYTHONUNBUFFERED=1 \
PYTHONHASHSEED=random \
PIP_NO_CACHE_DIR=on \
PIP_DISABLE_PIP_VERSION_CHECK=on \
PIP_DEFAULT_TIMEOUT=500

RUN apt-get update && apt-get install -y gcc
RUN python -m pip install --upgrade pip

WORKDIR /app/src
COPY ./requirements.txt ./
COPY ./shop_api ./shop_api

# ENV VIRTUAL_ENV=/app/src/.venv \
# PATH=/app/src/.venv/bin:$PATH

RUN pip install -r requirements.txt

FROM base as local

CMD ["uvicorn", "shop_api.main:app", "--port", "8080", "--host", "0.0.0.0"]
49 changes: 49 additions & 0 deletions hw2/hw/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
version: "3"

services:

local:
build:
context: .
dockerfile: ./Dockerfile
target: local
restart: always
ports:
- 8080:8080
environment:
- DATABASE_URL=postgresql+psycopg2://shop_user:shop_password@postgres:5432/shop_db
depends_on:
- postgres
- prometheus
- grafana

grafana:
image: grafana/grafana:latest
ports:
- 3000:3000
restart: always

prometheus:
image: prom/prometheus
volumes:
- ./settings/prometheus/:/etc/prometheus/
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--web.console.libraries=/usr/share/prometheus/console_libraries"
- "--web.console.templates=/usr/share/prometheus/consoles"
ports:
- 9090:9090
restart: always

postgres:
image: postgres:15-alpine
environment:
- POSTGRES_DB=shop_db
- POSTGRES_USER=shop_user
- POSTGRES_PASSWORD=shop_password
ports:
- 5432:5432
volumes:
- ./pgdata:/var/lib/postgresql/data
restart: always
Binary file added hw2/hw/grafana_example.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions hw2/hw/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
# Основные зависимости для ASGI приложения
fastapi>=0.117.1
uvicorn>=0.24.0
prometheus-fastapi-instrumentator==7.1.0

# Зависимости для тестирования
pytest>=7.4.0
pytest-asyncio>=0.21.0
httpx>=0.27.2
Faker>=37.8.0
pytest-cov>=5.0.0

# Database
SQLAlchemy>=2.0.36
psycopg2-binary>=2.9.10
10 changes: 10 additions & 0 deletions hw2/hw/settings/prometheus/prometheus.yml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 18 additions & 1 deletion hw2/hw/shop_api/main.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,20 @@
from fastapi import FastAPI
from contextlib import asynccontextmanager
from prometheus_fastapi_instrumentator import Instrumentator

from .routes import items_router, carts_router
from .storage import storage


@asynccontextmanager
async def lifespan(app: FastAPI):
_ = storage
yield


app = FastAPI(title="Shop API", lifespan=lifespan)
Instrumentator().instrument(app).expose(app)

app.include_router(items_router)
app.include_router(carts_router)

app = FastAPI(title="Shop API")
46 changes: 46 additions & 0 deletions hw2/hw/shop_api/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from typing import Optional
from pydantic import BaseModel, Field, field_validator, ConfigDict


class ItemCreate(BaseModel):
name: str
price: float = Field(gt=0)


class ItemUpdate(BaseModel):
name: str
price: float = Field(gt=0)


class ItemPatch(BaseModel):
model_config = ConfigDict(extra="forbid")

name: Optional[str] = None
price: Optional[float] = Field(None, gt=0)

@field_validator('price')
@classmethod
def validate_price(cls, v):
if v is not None and v <= 0:
raise ValueError('Price must be positive')
return v


class ItemResponse(BaseModel):
id: int
name: str
price: float
deleted: bool = False


class CartItemResponse(BaseModel):
id: int
name: str
quantity: int
available: bool


class CartResponse(BaseModel):
id: int
items: list[CartItemResponse]
price: float
4 changes: 4 additions & 0 deletions hw2/hw/shop_api/routes/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
from .items import router as items_router
from .carts import router as carts_router

__all__ = ["items_router", "carts_router"]
Loading