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
71 changes: 47 additions & 24 deletions .github/workflows/hw2-tests.yml
Original file line number Diff line number Diff line change
@@ -1,39 +1,62 @@
name: "HW2 Tests"
name: HW2 Tests

# Запускаем тесты при изменении файлов в hw2/hw/
on:
pull_request:
branches: [ main ]
paths: [ 'hw2/hw/**' ]
push:
branches: [ main ]
paths: [ 'hw2/hw/**' ]
branches: [ main, master ]
paths:
- 'hw2/**'
- '.github/workflows/hw2-tests.yml'
pull_request:
branches: [ main, master ]
paths:
- 'hw2/**'
- '.github/workflows/hw2-tests.yml'

jobs:
test-hw2:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.12", "3.13"]


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

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v4
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
python-version: '3.11'

- name: Install dependencies
working-directory: hw2/hw
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r hw2/hw/requirements.txt
pip install pytest-cov

- name: Run tests
working-directory: hw2/hw
- name: Run tests with coverage
env:
DATABASE_URL: postgresql+asyncpg://shop_user:shop_password@localhost:5432/shop_db
PYTHONPATH: ${{ github.workspace }}/hw2/hw
run: |
pytest test_homework2.py -v
cd hw2/hw
pytest test_homework2.py test_additional.py --cov=shop_api --cov-report=term --cov-report=xml --cov-fail-under=95 -v

- name: Upload coverage reports
uses: codecov/codecov-action@v4
if: always()
with:
file: ./hw2/hw/coverage.xml
flags: hw2
name: hw2-coverage
117 changes: 116 additions & 1 deletion hw1/app.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,105 @@
from typing import Any, Awaitable, Callable
import json
import math
from urllib.parse import parse_qs

async def respond(send, status: int, body: dict[str, Any]):
await send({
"type": "http.response.start",
"status": status,
"headers": [(b"content-type", b"application/json")],
})
await send({"type": "http.response.body", "body": json.dumps(body).encode()})

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

def parse_query(scope) -> dict[str, list[str]]:
return parse_qs(scope.get("query_string", b"").decode())

async def handle_fibonacci(method: str, path: str, send):
if method != "GET":
return await respond(send, 422, {"error": "Unsupported method"})
param = path[len("/fibonacci/") :]
if not param:
return await respond(send, 422, {"error": "Invalid n"})
try:
n = int(param)
except ValueError:
return await respond(send, 422, {"error": "Invalid n"})
if n < 0:
return await respond(send, 400, {"error": "n must be non-negative"})
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return await respond(send, 200, {"result": a})

async def handle_factorial(method: str, query: dict[str, list[str]], send):
if method != "GET":
return await respond(send, 422, {"error": "Unsupported method"})
raw = query.get("n")
if not raw or raw[0] == "":
return await respond(send, 422, {"error": "Invalid n"})
try:
n = int(raw[0])
except ValueError:
return await respond(send, 422, {"error": "Invalid n"})
if n < 0:
return await respond(send, 400, {"error": "n must be non-negative"})
return await respond(send, 200, {"result": math.factorial(n)})

async def handle_mean(method: str, query: dict[str, list[str]], receive, send):
if method != "GET":
return await respond(send, 422, {"error": "Unsupported method"})
body = await read_http_body(receive)

if body:
try:
data = json.loads(body.decode() or "null")
except json.JSONDecodeError:
return await respond(send, 422, {"error": "Invalid JSON body"})
if not isinstance(data, list):
return await respond(send, 422, {"error": "Invalid JSON body"})
if len(data) == 0:
return await respond(send, 400, {"error": "numbers must be non-empty list"})
nums: list[float] = []
for v in data:
if isinstance(v, (int, float)):
nums.append(float(v))
else:
return await respond(send, 422, {"error": "All items must be numbers"})
return await respond(send, 200, {"result": sum(nums) / len(nums)})

numbers_param = query.get("numbers", [None])[0]
if numbers_param is None:
return await respond(send, 422, {"error": "Invalid JSON body"})
parts = [p.strip() for p in numbers_param.split(",") if p.strip()]
if not parts:
return await respond(send, 400, {"error": "numbers must be non-empty list"})
try:
nums = [float(p) for p in parts]
except ValueError:
return await respond(send, 422, {"error": "All items must be numbers"})
return await respond(send, 200, {"result": sum(nums) / len(nums)})

async def handle_lifespan(receive, send):
while True:
msg = await receive()
t = msg.get("type")
if t == "lifespan.startup":
await send({"type": "lifespan.startup.complete"})
elif t == "lifespan.shutdown":
await send({"type": "lifespan.shutdown.complete"})
return

async def application(
scope: dict[str, Any],
receive: Callable[[], Awaitable[dict[str, Any]]],
Expand All @@ -12,7 +111,23 @@ async def application(
receive: Корутина для получения сообщений от клиента
send: Корутина для отправки сообщений клиенту
"""
# TODO: Ваша реализация здесь
if scope["type"] == "lifespan":
return await handle_lifespan(receive, send)
if scope["type"] != "http":
return

method = scope["method"]
path = scope["path"]
query = parse_query(scope)

if path.startswith("/fibonacci/"):
return await handle_fibonacci(method, path, send)
if path == "/factorial":
return await handle_factorial(method, query, send)
if path == "/mean":
return await handle_mean(method, query, receive, send)

return await respond(send, 404, {"error": "Not found"})

if __name__ == "__main__":
import uvicorn
Expand Down
6 changes: 6 additions & 0 deletions hw2/hw/.coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
[run]
source = shop_api
concurrency = thread,greenlet

[report]
precision = 2
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.13 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 . ./

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"]
Empty file removed hw2/hw/__init__.py
Empty file.
21 changes: 21 additions & 0 deletions hw2/hw/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import pytest
import asyncio
from sqlalchemy import text
from shop_api.database import Base, engine, async_session_maker


@pytest.fixture(scope="session", autouse=True)
def setup_database():
async def init_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)

asyncio.run(init_db())
yield

async def cleanup_db():
await engine.dispose()

asyncio.run(cleanup_db())

65 changes: 65 additions & 0 deletions hw2/hw/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
version: "3"

services:

postgres:
image: postgres:15-alpine
restart: always
environment:
POSTGRES_DB: shop_db
POSTGRES_USER: shop_user
POSTGRES_PASSWORD: shop_password
ports:
- 5432:5432
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- monitoring

shop-api:
build:
context: .
dockerfile: ./Dockerfile
target: local
restart: always
ports:
- 8080:8080
environment:
DATABASE_URL: postgresql+asyncpg://shop_user:shop_password@postgres:5432/shop_db
depends_on:
- postgres
networks:
- monitoring

grafana:
image: grafana/grafana:latest
ports:
- 3000:3000
restart: always
networks:
- monitoring
environment:
- GF_SECURITY_ADMIN_PASSWORD=admin
- GF_SECURITY_ADMIN_USER=admin

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
networks:
- monitoring

networks:
monitoring:
driver: bridge

volumes:
postgres_data:
6 changes: 6 additions & 0 deletions hw2/hw/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@
fastapi>=0.117.1
uvicorn>=0.24.0

prometheus-fastapi-instrumentator>=6.1.0

sqlalchemy>=2.0.0
asyncpg>=0.29.0

# Зависимости для тестирования
pytest>=7.4.0
pytest-asyncio>=0.21.0
pytest-cov>=4.1.0
httpx>=0.27.2
Faker>=37.8.0
12 changes: 12 additions & 0 deletions hw2/hw/run_demos.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/bin/bash

cd "$(dirname "$0")"

PYTHON=${PYTHON:-python3}

for script in transaction_demos/[0-9]*.py; do
echo "=== $script ==="
$PYTHON "$script"
echo ""
done

9 changes: 9 additions & 0 deletions hw2/hw/settings/prometheus/prometheus.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
global:
scrape_interval: 10s

scrape_configs:
- job_name: 'shop-api'
metrics_path: /metrics
static_configs:
- targets:
- shop-api:8080
Empty file removed hw2/hw/shop_api/__init__.py
Empty file.
Loading