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
25 changes: 23 additions & 2 deletions .github/workflows/hw2-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ jobs:
matrix:
python-version: ["3.12", "3.13"]

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

steps:
- name: Checkout code
uses: actions/checkout@v4
Expand All @@ -31,9 +46,15 @@ jobs:
python -m pip install --upgrade pip
pip install -r requirements.txt

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

- name: Check coverage threshold
working-directory: hw2/hw
run: |
pytest test_homework2.py -v
coverage report --fail-under=95
185 changes: 184 additions & 1 deletion hw1/app.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,170 @@
from typing import Any, Awaitable, Callable
import json
import math
from urllib.parse import parse_qs

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

async def read_body(receive) -> bytes:
"""Считывает всё тело (даже если придёт несколькими чанками)."""
chunks: list[bytes] = []
more = True
while more:
msg = await receive()
if msg["type"] != "http.request":
continue
chunks.append(msg.get("body", b""))
more = msg.get("more_body", False)
return b"".join(chunks)

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


class RouteMatch:
__slots__ = ("handler", "params")
def __init__(self, handler, params: dict[str, str] | None = None):
self.handler = handler
self.params = params or {}

class Router:

def __init__(self):
self._static: dict[tuple[str, str], Callable] = {}
self._dynamic: list[tuple[str, Callable]] = []

def add(self, method: str, path: str, handler: Callable) -> None:
if path.endswith("/{n}") and path.startswith("/fibonacci"):
self._dynamic.append(("/fibonacci/", handler))
else:
self._static[(method.upper(), path)] = handler

def match(self, method: str, path: str) -> RouteMatch | None:
key = (method.upper(), path)
if key in self._static:
return RouteMatch(self._static[key])

for prefix, handler in self._dynamic:
if path.startswith(prefix):
param = path[len(prefix):]
if param != "":
return RouteMatch(handler, {"n": param})
return None

router = Router()

async def view_fibonacci(
scope: dict[str, Any],
receive: Callable[[], Awaitable[dict[str, Any]]],
send: Callable[[dict[str, Any]], Awaitable[None]],
*,
n: str,
) -> None:
if scope["method"] != "GET":
return await json_response(send, 422, {"error": "Unsupported method"})

try:
ni = int(n)
except ValueError:
return await json_response(send, 422, {"error": "Invalid n"})
if ni < 0:
return await json_response(send, 400, {"error": "n must be non-negative"})

a, b = 0, 1
for _ in range(ni):
a, b = b, a + b
return await json_response(send, 200, {"result": a})

async def view_factorial(
scope: dict[str, Any],
receive: Callable[[], Awaitable[dict[str, Any]]],
send: Callable[[dict[str, Any]], Awaitable[None]],
) -> None:
if scope["method"] != "GET":
return await json_response(send, 422, {"error": "Unsupported method"})

query = as_query(scope)
raw = query.get("n")
if not raw or raw[0] == "":
return await json_response(send, 422, {"error": "Invalid n"})
try:
n = int(raw[0])
except ValueError:
return await json_response(send, 422, {"error": "Invalid n"})
if n < 0:
return await json_response(send, 400, {"error": "n must be non-negative"})

return await json_response(send, 200, {"result": math.factorial(n)})

async def view_mean(
scope: dict[str, Any],
receive: Callable[[], Awaitable[dict[str, Any]]],
send: Callable[[dict[str, Any]], Awaitable[None]],
) -> None:
if scope["method"] != "GET":
return await json_response(send, 422, {"error": "Unsupported method"})

body = await read_body(receive)
if body:
try:
data = json.loads(body.decode() or "null")
except json.JSONDecodeError:
return await json_response(send, 422, {"error": "Invalid JSON body"})
if not isinstance(data, list):
return await json_response(send, 422, {"error": "Invalid JSON body"})
if len(data) == 0:
return await json_response(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 json_response(send, 422, {"error": "All items must be numbers"})
return await json_response(send, 200, {"result": sum(nums) / len(nums)})

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


router.add("GET", "/factorial", view_factorial)
router.add("GET", "/mean", view_mean)
router.add("GET", "/fibonacci/{n}", view_fibonacci)

async def lifespan_app(
receive: Callable[[], Awaitable[dict[str, Any]]],
send: Callable[[dict[str, Any]], Awaitable[None]],
) -> None:
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(
Expand All @@ -12,7 +178,24 @@ async def application(
receive: Корутина для получения сообщений от клиента
send: Корутина для отправки сообщений клиенту
"""
# TODO: Ваша реализация здесь
stype = scope.get("type")
if stype == "lifespan":
return await lifespan_app(receive, send)

if stype != "http":
return

method = scope["method"]
path = scope["path"]

match = router.match(method, path)
if match is None:
return await json_response(send, 404, {"error": "Not found"})

if match.params:
return await match.handler(scope, receive, send, **match.params)
else:
return await match.handler(scope, receive, send)

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

[report]
precision = 2
exclude_lines =
pragma: no cover
def __repr__
raise AssertionError
raise NotImplementedError
if __name__ == .__main__.:
pass

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"]
29 changes: 29 additions & 0 deletions hw2/hw/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import pytest
import asyncio


@pytest.fixture(scope="session", autouse=True)
def setup_database():
from shop_api.database import engine, Base

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)

loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(init_db())
loop.close()

yield

async def cleanup_db():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)

loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(cleanup_db())
loop.close()

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

postgres:
image: postgres:16-alpine
restart: always
environment:
POSTGRES_USER: shop_user
POSTGRES_PASSWORD: shop_password
POSTGRES_DB: shop_db
ports:
- 5432:5432
networks:
- monitoring
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U shop_user -d shop_db"]
interval: 5s
timeout: 5s
retries: 5

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

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:
8 changes: 8 additions & 0 deletions hw2/hw/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@
fastapi>=0.117.1
uvicorn>=0.24.0

# Работа с БД
sqlalchemy>=2.0.0
asyncpg>=0.29.0

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

# Мониторинг
prometheus-fastapi-instrumentator>=6.1.0
Loading