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
8 changes: 8 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[run]
omit =
hw2/hw/shop_api/grpc_server.py
hw2/hw/shop_api/shop_pb2.py
hw2/hw/shop_api/shop_pb2_grpc.py
hw2/hw/shop_api/main.py


51 changes: 51 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: tests

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
pytest:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'

- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }}
restore-keys: |
${{ runner.os }}-pip-

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r hw2/hw/requirements.txt -r lecture5/requirements.txt

- name: Run tests with coverage (HW2)
env:
PYTHONPATH: hw2/hw
run: |
pytest -vv --maxfail=1 \
--cov=shop_api \
--cov-report=term-missing \
--cov-fail-under=95 \
hw2/hw/test_homework2.py

- name: Upload coverage (artifact)
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-report
path: ./.coverage*


119 changes: 118 additions & 1 deletion hw1/app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from typing import Any, Awaitable, Callable
import json
from urllib.parse import parse_qs


async def application(
Expand All @@ -12,7 +14,122 @@ async def application(
receive: Корутина для получения сообщений от клиента
send: Корутина для отправки сообщений клиенту
"""
# TODO: Ваша реализация здесь

method = scope.get("method", "").upper()
path = scope.get("path", "")

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

async def read_body() -> bytes:
chunks: list[bytes] = []
while True:
message = await receive()
if message.get("type") != "http.request":
continue
body = message.get("body", b"") or b""
if body:
chunks.append(body)
if not message.get("more_body", False):
break
return b"".join(chunks)

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

if path == "/factorial":
raw_qs = scope.get("query_string", b"")
qs = parse_qs(raw_qs.decode("utf-8"), keep_blank_values=True)
values = qs.get("n")
if not values or values[0] == "":
await send_json(422, {"detail": "Query parameter 'n' is required"})
return
try:
n = int(values[0])
except ValueError:
await send_json(422, {"detail": "Query parameter 'n' must be integer"})
return
if n < 0:
await send_json(400, {"detail": "'n' must be non-negative"})
return
# factorial
result = 1
for i in range(2, n + 1):
result *= i
await send_json(200, {"result": result})
return

if path == "/mean":
body = await read_body()
if not body:
await send_json(422, {"detail": "JSON body is required"})
return
try:
data = json.loads(body.decode("utf-8"))
except json.JSONDecodeError:
await send_json(422, {"detail": "Malformed JSON"})
return
if data is None:
await send_json(422, {"detail": "JSON body is required"})
return
if not isinstance(data, list) or len(data) == 0:
await send_json(400, {"detail": "Expected non-empty JSON array of numbers"})
return
# Validate all elements are numbers (int or float)
if not all((isinstance(x, (int, float)) and not isinstance(x, bool)) for x in data):
await send_json(400, {"detail": "Array must contain only numbers"})
return
total = float(sum(float(x) for x in data))
mean_value = total / len(data)
await send_json(200, {"result": mean_value})
return

if path.startswith("/fibonacci"):
if path == "/fibonacci":
await send_json(422, {"detail": "Path parameter 'n' is required"})
return
if not path.startswith("/fibonacci/"):
await send_json(404, {"detail": "Not Found"})
return
raw_n = path[len("/fibonacci/") :]
if raw_n == "":
await send_json(422, {"detail": "Path parameter 'n' is required"})
return
try:
n = int(raw_n)
except ValueError:
await send_json(422, {"detail": "Path parameter 'n' must be integer"})
return
if n < 0:
await send_json(400, {"detail": "'n' must be non-negative"})
return
# fibonacci
if n == 0:
fib = 0
elif n == 1:
fib = 1
else:
a, b = 0, 1
for _ in range(2, n + 1):
a, b = b, a + b
fib = b
await send_json(200, {"result": fib})
return

await send_json(404, {"detail": "Not Found"})

if __name__ == "__main__":
import uvicorn
Expand Down
115 changes: 115 additions & 0 deletions hw2/ddoser.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass
from random import choice, random
from time import perf_counter
import argparse

import requests
from faker import Faker


faker = Faker()


@dataclass
class LoadConfig:
base_url: str
concurrency: int
iterations_per_worker: int
timeout_s: float
items_to_seed: int


def create_item(session: requests.Session, base_url: str) -> int:
payload = {"name": faker.word(), "price": round(10 + random() * 90, 2)}
resp = session.post(f"{base_url}/item", json=payload, timeout=5)
resp.raise_for_status()
return int(resp.json()["id"]) if "id" in resp.json() else int(resp.json().get("id", 0))


def seed_items(session: requests.Session, base_url: str, count: int) -> list[int]:
item_ids: list[int] = []
for _ in range(count):
item_id = create_item(session, base_url)
item_ids.append(item_id)
return item_ids


def create_cart(session: requests.Session, base_url: str) -> int:
resp = session.post(f"{base_url}/cart", timeout=5)
resp.raise_for_status()
return int(resp.json()["id"])


def add_to_cart(session: requests.Session, base_url: str, cart_id: int, item_id: int) -> None:
resp = session.post(f"{base_url}/cart/{cart_id}/add/{item_id}", timeout=5)
resp.raise_for_status()


def list_items(session: requests.Session, base_url: str) -> None:
resp = session.get(f"{base_url}/item", timeout=5)
resp.raise_for_status()


def get_cart(session: requests.Session, base_url: str, cart_id: int) -> None:
resp = session.get(f"{base_url}/cart/{cart_id}", timeout=5)
resp.raise_for_status()


def worker(config: LoadConfig, worker_index: int, item_ids: list[int]) -> tuple[int, int]:
successes = 0
failures = 0
with requests.Session() as session:
cart_id = create_cart(session, config.base_url)
for _ in range(config.iterations_per_worker):
try:
list_items(session, config.base_url)
add_to_cart(session, config.base_url, cart_id, choice(item_ids))
get_cart(session, config.base_url, cart_id)
successes += 3
except Exception:
failures += 1
return successes, failures


def run_load(config: LoadConfig) -> None:
start = perf_counter()
with requests.Session() as s:
item_ids = seed_items(s, config.base_url, config.items_to_seed)

futures = []
successes = 0
failures = 0
with ThreadPoolExecutor(max_workers=config.concurrency) as executor:
for i in range(config.concurrency):
futures.append(executor.submit(worker, config, i, item_ids))
for fut in as_completed(futures):
ok, bad = fut.result()
successes += ok
failures += bad

duration = perf_counter() - start
rps = successes / duration if duration > 0 else 0.0
print(f"done: successes={successes}, failures={failures}, duration_s={duration:.2f}, approx_rps={rps:.1f}")


def parse_args() -> LoadConfig:
parser = argparse.ArgumentParser(description="Shop API load generator")
parser.add_argument("--base", default="http://localhost:8001", help="Base URL, default http://localhost:8001")
parser.add_argument("--concurrency", type=int, default=16, help="Concurrent workers")
parser.add_argument("--iterations", type=int, default=300, help="Iterations per worker")
parser.add_argument("--timeout", type=float, default=5.0, help="HTTP timeout seconds")
parser.add_argument("--seed-items", type=int, default=5, help="How many items to create before load")
args = parser.parse_args()
return LoadConfig(
base_url=args.base,
concurrency=args.concurrency,
iterations_per_worker=args.iterations,
timeout_s=args.timeout,
items_to_seed=args.seed_items,
)


if __name__ == "__main__":
cfg = parse_args()
run_load(cfg)
49 changes: 49 additions & 0 deletions hw2/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
services:
shop-api:
build:
context: ./hw
dockerfile: Dockerfile
container_name: shop-api
ports:
- "8001:8000" # FastAPI HTTP (includes /metrics)
- "50051:50051" # gRPC
networks:
- monitor-net

prometheus:
image: prom/prometheus:v2.54.1
container_name: prometheus
volumes:
- ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --web.enable-lifecycle
ports:
- "9090:9090"
depends_on:
- shop-api
networks:
- monitor-net

grafana:
image: grafana/grafana:11.2.0
container_name: grafana
ports:
- "3000:3000"
environment:
- GF_PATHS_PROVISIONING=/etc/grafana/provisioning
volumes:
- ./monitoring/grafana/datasources:/etc/grafana/provisioning/datasources:ro
- ./monitoring/grafana/dashboards:/etc/grafana/provisioning/dashboards:ro
- ./monitoring/grafana/dashboards_json:/var/lib/grafana/dashboards:ro
depends_on:
- prometheus
networks:
- monitor-net

networks:
monitor-net:
driver: bridge


23 changes: 23 additions & 0 deletions hw2/hw/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
FROM python:3.11-slim

ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
UVICORN_WORKERS=1

WORKDIR /app

# Install build deps for grpcio if needed
RUN apt-get update && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/*

COPY requirements.txt ./
RUN pip install -r requirements.txt

COPY . .

EXPOSE 8000 50051

# Run FastAPI (serves /metrics) and starts gRPC on startup event
CMD ["python", "-m", "uvicorn", "shop_api.main:app", "--host", "0.0.0.0", "--port", "8000"]


Loading
Loading